diff --git a/project_docs/UI_F1_SETTINGS_AND_F2_PLACEHOLDER_V1.md b/project_docs/UI_F1_SETTINGS_AND_F2_PLACEHOLDER_V1.md new file mode 100644 index 0000000..5645069 --- /dev/null +++ b/project_docs/UI_F1_SETTINGS_AND_F2_PLACEHOLDER_V1.md @@ -0,0 +1,224 @@ +# UI_F1_SETTINGS_AND_F2_PLACEHOLDER_V1 + +## 1. Doel + +Deze stap voegt een kleine, uitbreidbare UI-structuur toe voor twee functietoets-slots aan de functiebalk onder de panelen: + +- `F1 = Settings` +- `F2 = placeholder voor latere Rename` + +Doel: +- de functiebalk meer laten aansluiten op een klassieke file-manager workflow +- ruimte reserveren voor toekomstige uitbreiding zonder de huidige dual-pane workflow te verstoren +- een eerste container bieden voor instellingen en history/logweergave zonder nu al een grote instellingen-UI te ontwerpen + +Waarom nu `F1` als Settings: +- `F1` is een logische, laag-risico plek voor globale app-functionaliteit +- het conflicteert niet met bestaande file-acties zoals `F3`-`F8` +- een compacte modal houdt de hoofdworkspace schoon + +Waarom `F2` nu alvast reserveren: +- de functiebalk wordt voorbereid op een completere functietoets-reeks +- latere `Rename`-invulling kan op een stabiele plek landen +- het voorkomt dat de functiebalk later opnieuw semantisch moet verschuiven + +## 2. F1 Settings + +`F1` krijgt in v1 zowel een keyboard shortcut als een knop in de menubalk onder de panelen. + +Voorstel: +- keyboard shortcut: `F1` +- knoplabel in functiebalk: `Settings` +- positie: links in de functiebalk, vóór de bestaande file-acties +- actie: opent een compacte modal/popup + +Eigenschappen van de modal: +- compact genoeg om de dual-pane context niet te verdringen +- groot genoeg voor tabnavigatie en een eenvoudige lijstweergave +- uitbreidbaar zonder later een totaal andere structuur nodig te hebben + +## 3. Settings Modal Structuur + +De modal fungeert in v1 vooral als container. + +Vaste structuur: +- titel: `Settings` +- sluiten via `X` rechtsboven +- sluiten via `Escape` +- tabstrip bovenin of direct onder de titel + +Tabs in v1: +- `General` +- `Logs` + +Volgorde: +- `General` als eerste tab +- `Logs` direct rechts daarvan + +Rol van de tabs: +- `General`: placeholder voor latere instellingen +- `Logs`: eerste echte inhoud, gevoed door de bestaande history API + +Belangrijk: +- de modal is in v1 nog geen volledige instellingenpagina +- de tabstructuur wordt nu vooral neergezet zodat latere uitbreidingen logisch kunnen landen + +## 4. General Tab + +De `General`-tab is in v1 expliciet een placeholder. + +Inhoud in v1: +- nette sectieheader of korte toelichting +- compacte placeholdertekst, bijvoorbeeld dat toekomstige instellingen hier komen +- geen echte form controls vereist in deze fase + +Doel: +- semantische en visuele voorbereiding op latere app-instellingen +- voorkomen dat de modal nu al volledig op `Logs` leunt en later opnieuw ontworpen moet worden + +Niet in scope: +- root-configuratie +- theme-instellingen +- polling- of taskinstellingen +- bookmarkinstellingen +- geavanceerde preferences + +## 5. Logs Tab + +De `Logs`-tab gebruikt de bestaande `GET /api/history` API. + +Doel in v1: +- een compacte lijst van recente acties tonen zonder de hoofdworkspace te verstoren +- dezelfde informatie tonen die voor gebruikers praktisch nuttig is na `mkdir`, `rename`, `delete`, `copy` en `move` + +Aanbevolen zichtbare velden in v1: +- `operation` +- `status` +- hoofdpad of `source -> destination` +- tijdstip (`created_at` of compacte datum/tijd) +- foutmelding alleen wanneer `status = failed` + +Weergavevoorstel: +- compacte lijstregels +- recentste bovenaan +- status visueel herkenbaar: + - `queued` + - `completed` + - `failed` +- foutgevallen mogen een tweede compacte regel of muted subregel tonen + +Belangrijk: +- `Logs` vervangt geen tasklijst of taskdetail +- het is een compacte history-weergave in modalvorm +- de dual-pane workspace blijft onaangetast zolang de modal dicht is + +## 6. F2 Placeholder + +`F2` wordt in deze stap expliciet gereserveerd voor latere `Rename`. + +V1-uitwerking: +- keyboard shortcut: `F2` +- knop in functiebalk met label passend bij de toekomstige rol, bijvoorbeeld `Rename` +- nog geen functionele rename-actie via `F2` + +Twee opties: + +Optie A: +- knop disabled +- `F2` doet niets + +Optie B: +- knop klikbaar +- `F2` en knop tonen compacte melding: `Not implemented yet` + +Aanbeveling voor v1: +- gebruik een klikbare placeholder met compacte melding `Not implemented yet` + +Motivatie: +- maakt zichtbaar dat de plek bewust gereserveerd is +- voorkomt verwarring waarom `F2` volledig ontbreekt +- blijft lichtgewicht zonder nu al rename-semantiek te verschuiven + +Belangrijk: +- deze placeholder mag niets veranderen aan de bestaande rename-flow +- bestaande `Rename`-knop en bestaande file-manager interacties blijven leidend + +## 7. Relatie Met Bestaande Shortcuts + +`F1 Settings`: +- mag geen bestaande paneel- of file-actieflow breken +- werkt alleen als focus niet in een control zit, volgens bestaande shortcutguards +- terwijl de modal open is, mogen paneelshortcuts eronder niet doorwerken + +`F2 Placeholder`: +- mag geen bestaande flow beïnvloeden +- mag vooral `F6 Move` niet raken +- is semantisch gereserveerd, maar nog niet gekoppeld aan echte rename-logica + +Bestaande shortcuts blijven intact: +- `F3 = View` +- `F4 = Edit` +- `F5 = Copy` +- `F6 = Move` +- `F7 = MKdir` +- `F8 = Delete` + +## 8. UI-impact + +Waarschijnlijk te wijzigen frontendbestanden: +- `webui/html/index.html` +- `webui/html/app.js` +- `webui/html/style.css` +- `webui/backend/tests/golden/test_ui_smoke_golden.py` + +Verwachte impact: +- `index.html`: extra modalcontainer en functiebalkknoppen voor `F1` en `F2` +- `app.js`: shortcutbinding voor `F1`, placeholderbinding voor `F2`, tabs en history-fetch in settingsmodal +- `style.css`: compacte modal- en tabstyling die past bij de bestaande UI + +Backend: +- geen nieuwe backendwijzigingen nodig in deze stap +- `Logs` gebruikt de reeds bestaande history API + +## 9. Regressierisico + +Belangrijkste risico's: +- keyboard conflicts met bestaande shortcutlaag +- te volle functiebalk onder de panelen +- modal focusregels die bestaande paneelnavigatie per ongeluk laten doorwerken +- te grote settingsmodal die de workspace te zwaar onderbreekt + +Mitigatie: +- hergebruik bestaande modal- en focusguards +- houd `Settings` compact en centraal +- laat `F1` en `F2` exact via dezelfde centrale shortcut-dispatch lopen als andere functiebalkacties +- laat `F2` bewust geen echte file-actie starten in deze fase + +## 10. Teststrategie + +UI smoke/regressietests: +- controleer dat de functiebalk `Settings` bevat +- controleer dat een `F2`-placeholderknop aanwezig is +- controleer dat de settingsmodalcontainer in de HTML aanwezig is +- controleer dat de `General`- en `Logs`-tabs aanwezig zijn + +Handmatige validatie: +- `F1` opent en sluit correct via `Escape` en `X` +- `F1` blokkeert paneelkeyboardnavigatie terwijl de modal open is +- `Logs` laadt recente items via `/api/history` +- `F2` toont alleen de placeholderreactie en verandert geen file-flow +- bestaande `F6 Move` en overige functiebalkacties blijven intact + +## Aanbevolen v1-richting + +Aanbevolen implementatierichting met laag regressierisico: +- voeg `Settings` toe als compacte modal met tabstructuur +- maak `General` een nette placeholder +- gebruik `Logs` als eerste echte inhoud via de bestaande history API +- reserveer `F2` zichtbaar als placeholder met compacte `Not implemented yet` reactie + +Waarom dit de veiligste richting is: +- minimale verstoring van de bestaande dual-pane workflow +- geen backenduitbreiding nodig +- future-proof structuur voor settings en historyweergave +- `F2` krijgt een zichtbare, maar nog niet semantisch gevaarlijke plek diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index f4d768c..47f4131 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 0bae359..c1a1600 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 24e6bc9..c124ff5 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -37,8 +37,12 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="left-items"', body) self.assertIn('id="right-items"', body) self.assertIn('id="function-bar"', body) + self.assertIn('id="settings-btn"', body) + self.assertIn('id="rename-placeholder-btn"', body) self.assertIn('id="view-btn"', body) self.assertIn('id="edit-btn"', body) + self.assertIn("F1", body) + self.assertIn("F2", body) self.assertIn("F3", body) self.assertIn("F4", body) self.assertIn("F5", body) @@ -46,6 +50,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("F7", body) self.assertIn("F8", body) self.assertIn('id="viewer-modal"', body) + self.assertIn('id="settings-modal"', body) + self.assertIn('id="settings-general-tab"', body) + self.assertIn('id="settings-logs-tab"', body) + self.assertIn('id="settings-logs-list"', body) self.assertIn('id="viewer-content"', body) self.assertIn('id="editor-modal"', body) self.assertIn('id="editor-content"', body) @@ -68,6 +76,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertNotIn('id="tasks-panel"', body) ordered_ids = [ + 'id="settings-btn"', + 'id="rename-placeholder-btn"', 'id="view-btn"', 'id="edit-btn"', 'id="copy-btn"', @@ -78,6 +88,9 @@ class UiSmokeGoldenTest(unittest.TestCase): ] positions = [body.index(marker) for marker in ordered_ids] self.assertEqual(positions, sorted(positions)) + rename_placeholder_index = body.index('id="rename-placeholder-btn"') + disabled_index = body.index("disabled", rename_placeholder_index) + self.assertLess(rename_placeholder_index, disabled_index) def test_ui_static_assets_are_present_and_mapped(self) -> None: mount = self._ui_mount() @@ -89,6 +102,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('const THEME_STORAGE_KEY = "webmanager-theme"', app_js) self.assertIn("document.documentElement.dataset.theme", app_js) self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js) + self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) + self.assertIn('if (event.key === "F1") {', app_js) + self.assertIn('if (event.key === "F2") {', app_js) + self.assertIn('function openSettings(tab = "general")', app_js) + self.assertIn('await apiRequest("GET", "/api/history")', app_js) self.assertIn('Cross-root directory move is not supported in v1', app_js) self.assertIn('Batch directory move is not supported in v1', app_js) self.assertIn('Batch move requires all selected items to be in the same root', app_js) @@ -100,6 +118,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn(':root[data-theme="dark"]', style_css) self.assertIn(':root[data-theme="light"]', style_css) self.assertIn('#theme-toggle', style_css) + self.assertIn('.settings-card', style_css) + self.assertIn('.settings-tabs', style_css) app_js_url = app.url_path_for("ui", path="/app.js") style_css_url = app.url_path_for("ui", path="/style.css") diff --git a/webui/html/app.js b/webui/html/app.js index 4af0652..a062602 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -38,6 +38,10 @@ let batchMoveState = { destinationBase: "", count: 0, }; +let settingsState = { + activeTab: "general", + logsLoaded: false, +}; const THEME_STORAGE_KEY = "webmanager-theme"; function preferredTheme() { @@ -154,6 +158,19 @@ function batchMoveElements() { }; } +function settingsElements() { + return { + overlay: document.getElementById("settings-modal"), + closeButton: document.getElementById("settings-close-btn"), + generalTab: document.getElementById("settings-general-tab"), + logsTab: document.getElementById("settings-logs-tab"), + generalPanel: document.getElementById("settings-general-panel"), + logsPanel: document.getElementById("settings-logs-panel"), + logsList: document.getElementById("settings-logs-list"), + logsError: document.getElementById("settings-logs-error"), + }; +} + async function apiRequest(method, url, body) { const options = { method, headers: {} }; if (body !== undefined) { @@ -899,6 +916,12 @@ function actionShortcutHandled(event) { const noModifiers = !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey; if (noModifiers) { + if (event.key === "F1") { + return triggerActionButton("settings-btn"); + } + if (event.key === "F2") { + return false; + } if (event.key === "F3") { return triggerActionButton("view-btn"); } @@ -956,6 +979,10 @@ function wildcardPopupElements() { }; } +function isSettingsOpen() { + return !settingsElements().overlay.classList.contains("hidden"); +} + function isWildcardPopupOpen() { return !wildcardPopupElements().overlay.classList.contains("hidden"); } @@ -1234,6 +1261,92 @@ function closeViewer() { viewer.content.textContent = ""; } +function setSettingsTab(tab) { + const elements = settingsElements(); + settingsState.activeTab = tab === "logs" ? "logs" : "general"; + const isGeneral = settingsState.activeTab === "general"; + elements.generalTab.classList.toggle("is-active", isGeneral); + elements.generalTab.setAttribute("aria-selected", isGeneral ? "true" : "false"); + elements.logsTab.classList.toggle("is-active", !isGeneral); + elements.logsTab.setAttribute("aria-selected", !isGeneral ? "true" : "false"); + elements.generalPanel.classList.toggle("hidden", !isGeneral); + elements.logsPanel.classList.toggle("hidden", isGeneral); +} + +function formatHistoryLine(item) { + const timestamp = item.finished_at || item.created_at || ""; + const when = formatModified(timestamp); + const primaryPath = item.path || [item.source, item.destination].filter(Boolean).join(" -> "); + return { + title: `${item.operation} · ${item.status}`, + path: primaryPath || "-", + meta: when, + error: item.status === "failed" ? (item.error_message || item.error_code || "") : "", + }; +} + +function renderHistoryItems(items) { + const elements = settingsElements(); + elements.logsList.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + const empty = document.createElement("div"); + empty.className = "popup-meta"; + empty.textContent = "No history entries yet."; + elements.logsList.append(empty); + return; + } + for (const item of items) { + const line = formatHistoryLine(item); + const row = document.createElement("div"); + row.className = `settings-log-item status-${item.status}`; + const title = document.createElement("div"); + title.className = "settings-log-title"; + title.textContent = line.title; + const path = document.createElement("div"); + path.className = "settings-log-path"; + path.textContent = line.path; + const meta = document.createElement("div"); + meta.className = "settings-log-meta"; + meta.textContent = line.meta; + row.append(title, path, meta); + if (line.error) { + const error = document.createElement("div"); + error.className = "settings-log-error"; + error.textContent = line.error; + row.append(error); + } + elements.logsList.append(row); + } +} + +async function loadHistoryForSettings() { + const elements = settingsElements(); + elements.logsError.textContent = ""; + elements.logsList.innerHTML = ''; + try { + const data = await apiRequest("GET", "/api/history"); + renderHistoryItems(data.items || []); + settingsState.logsLoaded = true; + } catch (err) { + elements.logsList.innerHTML = ""; + elements.logsError.textContent = err.message; + } +} + +function closeSettings() { + settingsElements().overlay.classList.add("hidden"); +} + +async function openSettings(tab = "general") { + const elements = settingsElements(); + elements.overlay.classList.remove("hidden"); + setSettingsTab(tab); + if (settingsState.activeTab === "logs") { + await loadHistoryForSettings(); + } + (settingsState.activeTab === "logs" ? elements.logsTab : elements.generalTab).focus(); +} + function editorIsDirty() { return editorElements().content.value !== editorState.originalContent; } @@ -1419,6 +1532,14 @@ function clearSelectionForActivePane() { } function handleKeyboardShortcuts(event) { + if (isSettingsOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeSettings(); + return; + } + return; + } if (isBatchMovePopupOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -1555,6 +1676,7 @@ function setupEvents() { setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); document.getElementById("theme-toggle").onclick = toggleTheme; + document.getElementById("settings-btn").onclick = () => openSettings("general"); document.getElementById("view-btn").onclick = openViewer; document.getElementById("edit-btn").onclick = openEditor; document.getElementById("rename-btn").onclick = renameSelected; @@ -1563,6 +1685,19 @@ function setupEvents() { document.getElementById("move-btn").onclick = openF6Flow; document.getElementById("mkdir-btn").onclick = createFolderForActivePane; + const settings = settingsElements(); + settings.closeButton.onclick = closeSettings; + settings.generalTab.onclick = () => setSettingsTab("general"); + settings.logsTab.onclick = async () => { + setSettingsTab("logs"); + await loadHistoryForSettings(); + }; + settings.overlay.onclick = (event) => { + if (event.target === settings.overlay) { + closeSettings(); + } + }; + const wildcard = wildcardPopupElements(); wildcard.cancelButton.onclick = closeWildcardPopup; wildcard.applyButton.onclick = submitWildcardPopup; diff --git a/webui/html/index.html b/webui/html/index.html index 76a5da3..30e1c68 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -67,6 +67,8 @@ + +