diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js
index 1bf3efc..bd36697 100644
--- a/webgui/web/assets/js/app.js
+++ b/webgui/web/assets/js/app.js
@@ -6,7 +6,7 @@ import { onRoute } from "./state.js";
import { t, currentLocale, setLocale, onLocale } from "./i18n.js";
import { renderRemotes } from "./views/remotes.js";
import { renderBrowse } from "./views/browser.js";
-import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-details-1";
+import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-picker-restore-1";
import {
renderConfigureNew,
renderConfigureEdit,
diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js
index a721ffd..dc3e4d5 100644
--- a/webgui/web/assets/js/i18n.js
+++ b/webgui/web/assets/js/i18n.js
@@ -169,6 +169,11 @@ const STRINGS = {
"jobs.dest_remote": "Destination location",
"jobs.dest_path": "Destination path",
"jobs.path_placeholder": "folder/subfolder or /local/path",
+ "jobs.pick_folder": "Choose",
+ "jobs.pick_source_title": "Choose source folder",
+ "jobs.pick_dest_title": "Choose destination folder",
+ "jobs.pick_current": "Use current folder",
+ "jobs.pick_empty": "No folders here.",
"jobs.start": "Start Job",
"jobs.no_remotes_option": "(no remotes)",
"jobs.empty_title": "No jobs yet",
@@ -341,6 +346,11 @@ const STRINGS = {
"jobs.dest_remote": "目标位置",
"jobs.dest_path": "目标路径",
"jobs.path_placeholder": "文件夹/子文件夹 或 /local/path",
+ "jobs.pick_folder": "选择",
+ "jobs.pick_source_title": "选择源文件夹",
+ "jobs.pick_dest_title": "选择目标文件夹",
+ "jobs.pick_current": "使用当前文件夹",
+ "jobs.pick_empty": "这里没有文件夹。",
"jobs.start": "开始任务",
"jobs.no_remotes_option": "(无远程存储)",
"jobs.empty_title": "尚无任务",
diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js
index 6636ffe..c2d9163 100644
--- a/webgui/web/assets/js/views/jobs.js
+++ b/webgui/web/assets/js/views/jobs.js
@@ -19,6 +19,7 @@ let pollTimer = null;
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
const LOCAL_FS_VALUE = "__local__";
const expandedJobs = new Set();
+const LOCAL_PICKER_ROOT = "/root";
export async function renderJobs() {
const app = document.getElementById("app");
@@ -86,7 +87,10 @@ export async function renderNewJob() {
@@ -96,7 +100,10 @@ export async function renderNewJob() {
${t("jobs.local_path_help")}
@@ -114,6 +121,21 @@ export async function renderNewJob() {
}
const form = document.getElementById("new-job-form");
+ form.querySelector('[data-pick="src"]').addEventListener("click", () => {
+ openFolderPicker({
+ location: form.elements.srcRemote.value,
+ pathInput: form.elements.srcPath,
+ title: t("jobs.pick_source_title"),
+ });
+ });
+ form.querySelector('[data-pick="dst"]').addEventListener("click", () => {
+ openFolderPicker({
+ location: form.elements.dstRemote.value,
+ pathInput: form.elements.dstPath,
+ title: t("jobs.pick_dest_title"),
+ });
+ });
+
form.addEventListener("submit", async (e) => {
e.preventDefault();
const action = form.elements.action.value;
@@ -159,6 +181,124 @@ function buildJobFs(location, rawPath) {
return remotePath ? `${location}${remotePath}` : location;
}
+async function openFolderPicker({ location, pathInput, title }) {
+ const root = document.getElementById("modal-root");
+ let current = initialPickerPath(location, pathInput.value);
+
+ const close = () => {
+ root.innerHTML = "";
+ };
+
+ const choose = () => {
+ pathInput.value = current;
+ close();
+ };
+
+ const render = async () => {
+ root.innerHTML = `
+
+
+
${escapeHtml(title)}
+
+ ${escapeHtml(displayPickerPath(location, current))}
+
+
+
${t("loading.title")}…
+
+
+
+
+
+
+
+ `;
+
+ const overlay = root.querySelector(".modal-overlay");
+ const list = root.querySelector(".folder-picker-list");
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) close();
+ });
+ root.querySelector("[data-cancel]").addEventListener("click", close);
+ root.querySelector("[data-select]").addEventListener("click", choose);
+
+ try {
+ const dirs = await listDirs(location, current);
+ const parent = parentPickerPath(location, current);
+ list.innerHTML = `
+
+ ${dirs.length === 0 ? `
${t("jobs.pick_empty")}
` : dirs.map((dir) => `
+
+ `).join("")}
+ `;
+ list.querySelectorAll("[data-path]").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ if (btn.disabled) return;
+ current = btn.dataset.path;
+ render();
+ });
+ });
+ } catch (e) {
+ list.innerHTML = `
${t("error.couldnt_list")}
${escapeHtml(e.message)}
`;
+ }
+ };
+
+ await render();
+}
+
+async function listDirs(location, current) {
+ const isLocal = location === LOCAL_FS_VALUE;
+ const body = isLocal
+ ? { fs: current || LOCAL_PICKER_ROOT, remote: "" }
+ : { fs: location, remote: trimRemotePath(current) };
+ const res = await post("operations/list", {
+ ...body,
+ opt: { dirsOnly: true, noModTime: true, noMimeType: true, showHash: false },
+ });
+ const items = (res && res.list) || [];
+ return items
+ .filter((item) => item.IsDir)
+ .sort((a, b) => a.Name.localeCompare(b.Name));
+}
+
+function initialPickerPath(location, rawPath) {
+ const value = rawPath.trim();
+ if (location === LOCAL_FS_VALUE) {
+ return value || LOCAL_PICKER_ROOT;
+ }
+ return trimRemotePath(value);
+}
+
+function displayPickerPath(location, current) {
+ if (location === LOCAL_FS_VALUE) {
+ return current || "/";
+ }
+ return current ? `${location}${current}` : location;
+}
+
+function parentPickerPath(location, current) {
+ if (location === LOCAL_FS_VALUE) {
+ if (!current || current === "/") return null;
+ const normalized = current.replace(/\/+$/g, "");
+ const parent = normalized.slice(0, normalized.lastIndexOf("/")) || "/";
+ return parent;
+ }
+ if (!current) return null;
+ const parts = trimRemotePath(current).split("/").filter(Boolean);
+ parts.pop();
+ return parts.join("/");
+}
+
+function joinPickerPath(current, name) {
+ if (!current || current === "/") return current === "/" ? `/${name}` : name;
+ return `${current.replace(/\/+$/g, "")}/${name}`;
+}
+
+function trimRemotePath(path) {
+ return path.trim().replace(/^\/+|\/+$/g, "");
+}
+
async function refreshJobs() {
const card = document.getElementById("jobs-card");
if (!card) return false;
@@ -431,6 +571,10 @@ function escapeHtml(s) {
.replace(/'/g, "'");
}
+function escapeAttr(s) {
+ return escapeHtml(s);
+}
+
function formatDateTime(value) {
if (!value) return "";
const date = new Date(value);
diff --git a/webgui/web/assets/styles/components.css b/webgui/web/assets/styles/components.css
index abd23f9..ae174de 100644
--- a/webgui/web/assets/styles/components.css
+++ b/webgui/web/assets/styles/components.css
@@ -303,6 +303,21 @@
color: var(--color-muted);
}
+.input-action {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+}
+
+.input-action .input {
+ flex: 1;
+ min-width: 0;
+}
+
+.input-action .btn {
+ flex-shrink: 0;
+}
+
/* ---------- Badges ---------- */
.badge {
display: inline-flex;
@@ -580,6 +595,49 @@
border-color: transparent;
}
+.folder-picker {
+ max-width: 640px;
+}
+
+.folder-picker-path {
+ padding: var(--space-sm) var(--space-md);
+ background-color: var(--color-surface-dark-elevated);
+ border-radius: var(--radius-md);
+ color: var(--color-on-dark-soft);
+ font: var(--typo-code);
+ word-break: break-all;
+}
+
+.folder-picker-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ max-height: 360px;
+ overflow: auto;
+}
+
+.folder-picker-row {
+ width: 100%;
+ padding: var(--space-sm) var(--space-md);
+ background-color: var(--color-surface-dark-elevated);
+ color: var(--color-on-dark);
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ text-align: left;
+ font: var(--typo-body-sm);
+ cursor: pointer;
+}
+
+.folder-picker-row:hover {
+ border-color: var(--color-primary);
+}
+
+.folder-picker-row:disabled {
+ color: var(--color-muted-soft);
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
/* ---------- Footer ---------- */
.footer {
background-color: var(--color-surface-dark);
diff --git a/webgui/web/index.html b/webgui/web/index.html
index 22c053b..7899c9f 100644
--- a/webgui/web/index.html
+++ b/webgui/web/index.html
@@ -7,7 +7,7 @@
-
+