let state = { panes: { left: { currentPath: "storage1", showHidden: false, selectedItem: null, selectedItems: [], visibleItems: [], currentRowIndex: -1, }, right: { currentPath: "storage1", showHidden: false, selectedItem: null, selectedItems: [], visibleItems: [], currentRowIndex: -1, }, }, activePane: "left", selectedTaskId: null, lastTaskCount: 0, }; const ROW_JUMP_STEP = 10; function paneState(pane) { return state.panes[pane]; } function otherPane(pane) { return pane === "left" ? "right" : "left"; } function activePaneState() { return paneState(state.activePane); } function setStatus(msg) { document.getElementById("status").textContent = msg; } function setError(id, msg) { document.getElementById(id).textContent = msg || ""; } function setActionError(action, err) { setError("actions-error", `${action}: ${err.message}`); } function showActionSummary(action, successes, failures, firstError) { const base = `${action}: ${successes} success, ${failures} failed`; if (firstError) { setError("actions-error", `${base}. First error: ${firstError}`); } else { setError("actions-error", ""); } setStatus(base); } async function apiRequest(method, url, body) { const options = { method, headers: {} }; if (body !== undefined) { options.headers["Content-Type"] = "application/json"; options.body = JSON.stringify(body); } const response = await fetch(url, options); const data = await response.json().catch(() => ({})); if (!response.ok) { const error = data.error || {}; throw new Error(error.message || `HTTP ${response.status}`); } return data; } async function refreshTasksSnapshot() { try { const data = await apiRequest("GET", "/api/tasks"); state.lastTaskCount = Array.isArray(data.items) ? data.items.length : state.lastTaskCount; } catch (_) { // Task list panel is not visible in current UI; silently keep flow stable. } } function createButton(text, onClick) { const button = document.createElement("button"); button.textContent = text; button.onclick = onClick; return button; } function setActivePane(pane) { state.activePane = pane; document.getElementById("active-pane-label").textContent = pane; document.getElementById("left-pane").classList.toggle("active-pane", pane === "left"); document.getElementById("right-pane").classList.toggle("active-pane", pane === "right"); updateActionButtons(); } function setSelectedItem(pane, item) { const model = paneState(pane); model.selectedItem = item; model.selectedItems = item ? [item] : []; updateActionButtons(); } function selectedPaths(pane) { return paneState(pane).selectedItems.map((item) => item.path); } function setSingleSelection(pane, item) { setSelectedItem(pane, item); } function toggleSelection(pane, item) { const model = paneState(pane); const index = model.selectedItems.findIndex((selected) => selected.path === item.path); if (index >= 0) { const removed = model.selectedItems[index]; model.selectedItems.splice(index, 1); if (model.selectedItem && model.selectedItem.path === removed.path) { model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null; } } else { model.selectedItems.push(item); model.selectedItem = item; } updateActionButtons(); } function currentRowItem(pane) { const model = paneState(pane); if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { return null; } if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) { return null; } return model.visibleItems[model.currentRowIndex]; } function updateActionButtons() { const selectedItems = activePaneState().selectedItems; const count = selectedItems.length; const hasSelection = count > 0; const exactlyOne = count === 1; const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); document.getElementById("rename-btn").disabled = !exactlyOne; document.getElementById("delete-btn").disabled = !hasSelection; document.getElementById("copy-btn").disabled = !allFiles; document.getElementById("move-btn").disabled = !allFiles; } function currentParentPath(path) { if (!path.includes("/")) { return null; } const segments = path.split("/"); if (segments.length === 2) { return segments[0]; } return segments.slice(0, -1).join("/"); } function renderBreadcrumbs(pane, path) { const nav = document.getElementById(`${pane}-breadcrumbs`); nav.innerHTML = ""; const parts = path.split("/"); let aggregate = ""; for (let i = 0; i < parts.length; i += 1) { aggregate = i === 0 ? parts[i] : `${aggregate}/${parts[i]}`; const crumbPath = aggregate; const crumb = createButton(parts[i], () => { setActivePane(pane); navigateTo(pane, crumbPath); }); crumb.type = "button"; crumb.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); setActivePane(pane); navigateTo(pane, crumbPath); }; nav.append(crumb); if (i < parts.length - 1) { const sep = document.createElement("span"); sep.textContent = "/"; nav.append(sep); } } } function formatModified(isoString) { if (!isoString) { return "-"; } const d = new Date(isoString); if (Number.isNaN(d.getTime())) { return isoString; } const date = d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }); const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); return `${date} ${time}`; } function createBrowseItem(pane, entry, kind) { const li = document.createElement("li"); li.className = "selectable"; li.dataset.path = entry.path; const paths = selectedPaths(pane); const model = paneState(pane); if (paths.includes(entry.path)) { li.classList.add("is-selected"); } if (model.currentRowIndex >= 0 && model.visibleItems[model.currentRowIndex]?.path === entry.path) { li.classList.add("is-current-row"); } li.onclick = () => { setActivePane(pane); setSingleSelection(pane, { path: entry.path, name: entry.name, kind }); loadBrowsePane(pane); }; const marker = document.createElement("input"); marker.type = "checkbox"; marker.className = "select-marker"; marker.checked = paths.includes(entry.path); marker.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); toggleSelection(pane, { path: entry.path, name: entry.name, kind }); loadBrowsePane(pane); }; li.append(marker); const name = document.createElement("span"); name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`; if (kind === "directory") { const open = document.createElement("button"); open.className = "dir-link"; open.textContent = `${entry.name}/`; open.type = "button"; open.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); navigateTo(pane, entry.path); }; name.append(open); } else { const fileName = document.createElement("span"); fileName.textContent = entry.name; fileName.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); setSingleSelection(pane, { path: entry.path, name: entry.name, kind }); loadBrowsePane(pane); }; name.append(fileName); } li.append(name); const size = document.createElement("span"); size.className = "entry-size"; size.textContent = kind === "directory" ? "-" : String(entry.size); li.append(size); const modified = document.createElement("span"); modified.className = "entry-modified"; modified.textContent = formatModified(entry.modified); li.append(modified); return li; } function scrollCurrentRowIntoView(pane) { const model = paneState(pane); if (model.currentRowIndex < 0) { return; } const list = document.getElementById(`${pane}-items`); const row = list.querySelector(`li[data-row-index="${model.currentRowIndex}"]`); if (row) { row.scrollIntoView({ block: "nearest" }); } } function renderPaneItems(pane) { const model = paneState(pane); const items = document.getElementById(`${pane}-items`); items.innerHTML = ""; if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { model.currentRowIndex = -1; updateActionButtons(); return; } if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) { model.currentRowIndex = 0; } model.visibleItems.forEach((entry, index) => { if (entry.isParent) { const up = document.createElement("li"); up.className = "selectable"; up.dataset.rowIndex = String(index); up.dataset.path = entry.path; if (index === model.currentRowIndex) { up.classList.add("is-current-row"); } up.onclick = () => { setActivePane(pane); model.currentRowIndex = index; renderPaneItems(pane); }; up.append(document.createElement("span")); const upName = document.createElement("button"); upName.type = "button"; upName.className = "dir-link"; upName.textContent = "../"; upName.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); navigateTo(pane, entry.path); }; const upNameCell = document.createElement("span"); upNameCell.className = "entry-name entry-dir"; upNameCell.append(upName); up.append(upNameCell); const upSize = document.createElement("span"); upSize.className = "entry-size"; upSize.textContent = "-"; up.append(upSize); const upModified = document.createElement("span"); upModified.className = "entry-modified"; upModified.textContent = "-"; up.append(upModified); items.append(up); return; } const row = createBrowseItem(pane, entry, entry.kind); row.dataset.rowIndex = String(index); if (index === model.currentRowIndex) { row.classList.add("is-current-row"); } row.onclick = () => { setActivePane(pane); model.currentRowIndex = index; setSingleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind }); renderPaneItems(pane); }; const checkbox = row.querySelector(".select-marker"); if (checkbox) { checkbox.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); model.currentRowIndex = index; toggleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind }); renderPaneItems(pane); }; } const dirLink = row.querySelector(".dir-link"); if (dirLink) { dirLink.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); navigateTo(pane, entry.path); }; } const fileName = row.querySelector(".entry-file span"); if (fileName) { fileName.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); model.currentRowIndex = index; setSingleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind }); renderPaneItems(pane); }; } items.append(row); }); updateActionButtons(); } async function loadBrowsePane(pane) { setError(`${pane}-browse-error`, ""); try { const model = paneState(pane); const query = new URLSearchParams({ path: model.currentPath, show_hidden: String(model.showHidden), }); const data = await apiRequest("GET", `/api/browse?${query.toString()}`); model.currentPath = data.path; document.getElementById(`${pane}-current-path`).textContent = data.path; renderBreadcrumbs(pane, data.path); const visibleItems = []; const parent = currentParentPath(data.path); if (parent) { visibleItems.push({ path: parent, name: "..", kind: "directory", isParent: true }); } for (const entry of data.directories) { visibleItems.push({ ...entry, kind: "directory" }); } for (const entry of data.files) { visibleItems.push({ ...entry, kind: "file" }); } model.visibleItems = visibleItems; const visiblePaths = new Set(visibleItems.filter((item) => !item.isParent).map((item) => item.path)); model.selectedItems = model.selectedItems.filter((item) => visiblePaths.has(item.path)); if (model.selectedItem && !visiblePaths.has(model.selectedItem.path)) { model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null; } renderPaneItems(pane); scrollCurrentRowIntoView(pane); setStatus(`Loaded ${pane}: ${data.path}`); } catch (err) { setError(`${pane}-browse-error`, `Browse: ${err.message}`); } } function navigateTo(pane, path) { const model = paneState(pane); model.currentPath = path; model.currentRowIndex = 0; setSelectedItem(pane, null); loadBrowsePane(pane); } async function createFolderForPane(pane) { setActivePane(pane); const name = window.prompt("Folder name"); if (!name) { return; } setError(`${pane}-browse-error`, ""); try { await apiRequest("POST", "/api/files/mkdir", { parent_path: paneState(pane).currentPath, name, }); await loadBrowsePane(pane); } catch (err) { setError(`${pane}-browse-error`, `Create folder: ${err.message}`); } } async function createFolderForActivePane() { await createFolderForPane(state.activePane); } async function renameSelected() { const pane = state.activePane; const selectedItems = paneState(pane).selectedItems; if (selectedItems.length !== 1) { return; } const selected = selectedItems[0]; const newName = window.prompt("New name", selected.name); if (!newName) { return; } setError("actions-error", ""); try { await apiRequest("POST", "/api/files/rename", { path: selected.path, new_name: newName, }); setSelectedItem(pane, null); await loadBrowsePane(pane); } catch (err) { setActionError("Rename", err); } } async function deleteSelected() { const pane = state.activePane; const selectedItems = [...paneState(pane).selectedItems]; if (selectedItems.length === 0) { return; } if (!window.confirm(`Delete ${selectedItems.length} selected item(s)?`)) { return; } setError("actions-error", ""); let successes = 0; let failures = 0; let firstError = null; for (const item of selectedItems) { try { await apiRequest("POST", "/api/files/delete", { path: item.path }); successes += 1; } catch (err) { failures += 1; if (!firstError) { firstError = `${item.path}: ${err.message}`; } } } setSelectedItem(pane, null); await loadBrowsePane(pane); showActionSummary("Delete", successes, failures, firstError); } function defaultDestination(sourcePath, targetBasePath) { const sourceName = sourcePath.slice(sourcePath.lastIndexOf("/") + 1); return `${targetBasePath}/${sourceName}`; } async function startCopySelected() { const sourcePane = state.activePane; const destinationPane = otherPane(sourcePane); const selectedItems = [...paneState(sourcePane).selectedItems]; if (selectedItems.length === 0) { return; } const baseDestination = paneState(destinationPane).currentPath; setError("actions-error", ""); let successes = 0; let failures = 0; let firstError = null; for (const item of selectedItems) { const destination = defaultDestination(item.path, baseDestination); try { if (item.kind !== "file") { throw new Error("Only files are supported for copy"); } const result = await apiRequest("POST", "/api/files/copy", { source: item.path, destination, }); state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); successes += 1; } catch (err) { failures += 1; if (!firstError) { firstError = `${item.path}: ${err.message}`; } } } await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); showActionSummary("Copy", successes, failures, firstError); } async function startMoveSelected() { const sourcePane = state.activePane; const destinationPane = otherPane(sourcePane); const selectedItems = [...paneState(sourcePane).selectedItems]; if (selectedItems.length === 0) { return; } const baseDestination = paneState(destinationPane).currentPath; setError("actions-error", ""); let successes = 0; let failures = 0; let firstError = null; for (const item of selectedItems) { const destination = defaultDestination(item.path, baseDestination); try { if (item.kind !== "file") { throw new Error("Only files are supported for move"); } const result = await apiRequest("POST", "/api/files/move", { source: item.path, destination, }); state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); successes += 1; } catch (err) { failures += 1; if (!firstError) { firstError = `${item.path}: ${err.message}`; } } } setSelectedItem(sourcePane, null); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); showActionSummary("Move", successes, failures, firstError); } async function addBookmark() { const pane = state.activePane; const path = paneState(pane).currentPath; const label = window.prompt("Bookmark label", path); if (!label) { return; } setError("actions-error", ""); try { await apiRequest("POST", "/api/bookmarks", { path, label }); setStatus(`Bookmark added for ${path}`); } catch (err) { setActionError("Add bookmark", err); } } function shouldHandleShortcut(target) { if (!target || !(target instanceof Element)) { return true; } if (target.closest("[contenteditable='true']")) { return false; } const control = target.closest("input, textarea, select, button"); if (!control) { return true; } if (control.tagName === "INPUT" && control.type === "checkbox") { return false; } return false; } function moveCurrentRow(delta) { const pane = state.activePane; const model = paneState(pane); if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { model.currentRowIndex = -1; return; } if (model.currentRowIndex < 0) { model.currentRowIndex = 0; } else { const maxIndex = model.visibleItems.length - 1; model.currentRowIndex = Math.max(0, Math.min(maxIndex, model.currentRowIndex + delta)); } renderPaneItems(pane); scrollCurrentRowIntoView(pane); } function jumpCurrentRow(edge) { const pane = state.activePane; const model = paneState(pane); if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { model.currentRowIndex = -1; return; } model.currentRowIndex = edge === "start" ? 0 : model.visibleItems.length - 1; renderPaneItems(pane); scrollCurrentRowIntoView(pane); } function openCurrentDirectory() { const pane = state.activePane; const item = currentRowItem(pane); if (!item || item.kind !== "directory") { return; } navigateTo(pane, item.path); } function toggleCurrentSelection() { const pane = state.activePane; const item = currentRowItem(pane); if (!item || item.isParent) { return; } toggleSelection(pane, { path: item.path, name: item.name, kind: item.kind }); renderPaneItems(pane); } function clearSelectionForActivePane() { const pane = state.activePane; setSelectedItem(pane, null); renderPaneItems(pane); } function handleKeyboardShortcuts(event) { if (!shouldHandleShortcut(event.target)) { return; } if (event.metaKey && event.key === "ArrowUp") { event.preventDefault(); jumpCurrentRow("start"); return; } if (event.metaKey && event.key === "ArrowDown") { event.preventDefault(); jumpCurrentRow("end"); return; } if (event.altKey && event.key === "ArrowUp") { event.preventDefault(); moveCurrentRow(-ROW_JUMP_STEP); return; } if (event.altKey && event.key === "ArrowDown") { event.preventDefault(); moveCurrentRow(ROW_JUMP_STEP); return; } if (event.key === "Tab") { event.preventDefault(); setActivePane(otherPane(state.activePane)); return; } if (event.key === "ArrowUp") { event.preventDefault(); moveCurrentRow(-1); return; } if (event.key === "ArrowDown") { event.preventDefault(); moveCurrentRow(1); return; } if (event.key === "Enter") { event.preventDefault(); openCurrentDirectory(); return; } if (event.key === " " || event.code === "Space") { event.preventDefault(); toggleCurrentSelection(); return; } if (event.key === "Escape") { event.preventDefault(); clearSelectionForActivePane(); } } function setupPaneEvents(pane) { document.getElementById(`${pane}-pane`).onclick = () => setActivePane(pane); document.getElementById(`${pane}-hidden-toggle`).onchange = (ev) => { setActivePane(pane); paneState(pane).showHidden = ev.target.checked; loadBrowsePane(pane); }; } function setupEvents() { setupPaneEvents("left"); setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); document.getElementById("rename-btn").onclick = renameSelected; document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("copy-btn").onclick = startCopySelected; document.getElementById("move-btn").onclick = startMoveSelected; document.getElementById("mkdir-btn").onclick = createFolderForActivePane; document.getElementById("add-bookmark-btn").onclick = addBookmark; } async function init() { setError("actions-error", ""); setActivePane("left"); setupEvents(); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); } init();