From d644c5bb798224b7bf05d555ff3ed4604c77b924 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 21 Jun 2026 16:01:21 +0800 Subject: [PATCH] feat: mouse back/forward navigation + history stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C patch (vendor/.../window.c): intercept GDK button 8/9 → dispatch CustomEvent('verstak:navigate-back'/'verstak:navigate-forward') - App.svelte: navigation stack (snapshot-based history), alt+arrows, mouse button back/forward handlers, onNavigateBack/Forward - WorkbenchHost: close via navigate-back event - WorkspaceHost: workspace tab bar + tool panels - wails-mock: full navigation, sidebar, vau... --- .gitignore | 1 + frontend/e2e/files-plugin.spec.js | 143 ++++++++++++- frontend/e2e/plugin-manager-layout.spec.js | 12 ++ frontend/src/App.svelte | 143 ++++++++++++- frontend/src/lib/shell/WorkbenchHost.svelte | 29 +++ frontend/src/lib/shell/WorkspaceHost.svelte | 104 ++++++---- frontend/src/lib/test/wails-mock.js | 216 ++++++++++++++++++-- frontend/src/lib/ui/Icon.svelte | 4 + internal/api/app.go | 1 + 9 files changed, 589 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index acd68a7..138c3b1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ build/bin/verstak-desktop smoke-platform plugins/ vendor/ +\nfrontend/e2e-results/ diff --git a/frontend/e2e/files-plugin.spec.js b/frontend/e2e/files-plugin.spec.js index b005927..9311862 100644 --- a/frontend/e2e/files-plugin.spec.js +++ b/frontend/e2e/files-plugin.spec.js @@ -77,8 +77,7 @@ test.describe('G: Files Plugin', () => { await expect(page.locator('[data-resource-path="Project/Daily/Journal.md"]')).toBeVisible(); await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); - await expect(page.locator('[data-file-name="Daily"]')).toBeVisible({ timeout: 10000 }); - await page.locator('[data-file-name="Daily"]').dblclick(); + await expect(page.locator('.files-breadcrumb')).toContainText('Daily', { timeout: 10000 }); await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible({ timeout: 10000 }); await page.locator('[data-file-name="Journal.md"]').click(); page.once('dialog', (dialog) => dialog.accept()); @@ -89,6 +88,146 @@ test.describe('G: Files Plugin', () => { await expect(page.locator('.files-breadcrumb')).not.toContainText('Daily'); }); + test('files explorer uses icon controls and no row New Here action', async ({ page }) => { + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); + await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); + + for (const action of ['back', 'forward', 'up', 'refresh', 'new-folder', 'new-markdown', 'new-text', 'open', 'rename', 'trash', 'cut', 'copy', 'paste']) { + const button = page.locator(`[data-files-action="${action}"]`); + await expect(button).toHaveAttribute('title', /.+/); + await expect(button.locator('svg')).toBeVisible(); + await expect(button).not.toHaveText(/\S/); + } + + await expect(page.locator('.files-row-btn').filter({ hasText: 'New here' })).toHaveCount(0); + const firstRowButton = page.locator('[data-file-name="Notes"] .files-row-btn').first(); + await expect(firstRowButton).toBeVisible(); + await expect(firstRowButton).not.toHaveText(/\S/); + expect(await firstRowButton.evaluate((node) => node.innerHTML)).toContain(' { + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); + await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); + + await page.locator('[data-files-action="new-folder"]').click(); + await page.locator('[data-files-create-input]').fill('CutMe'); + await page.locator('[data-files-create-confirm]').click(); + await page.locator('[data-files-action="new-folder"]').click(); + await page.locator('[data-files-create-input]').fill('Target'); + await page.locator('[data-files-create-confirm]').click(); + + await page.locator('[data-file-name="CutMe"]').click({ button: 'right' }); + await page.locator('[data-files-menu-action="cut"]').click(); + + await page.locator('[data-file-name="Target"]').dblclick(); + await expect(page.locator('.files-breadcrumb')).toContainText('Target'); + await page.locator('[data-files-list]').click({ button: 'right', position: { x: 24, y: 110 } }); + await page.locator('[data-files-menu-action="paste"]').click(); + + await expect(page.locator('[data-file-name="CutMe"]')).toBeVisible(); + await page.locator('[data-files-action="up"]').click(); + await expect(page.locator('[data-file-name="CutMe"]')).toHaveCount(0); + }); + + test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => { + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); + await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); + + await page.locator('[data-files-action="new-folder"]').click(); + await page.locator('[data-files-create-input]').fill('DropTarget'); + await page.locator('[data-files-create-confirm]').click(); + await page.locator('[data-files-action="new-markdown"]').click(); + await page.locator('[data-files-create-input]').fill('DragOne.md'); + await page.locator('[data-files-create-confirm]').click(); + await page.locator('[data-files-action="new-text"]').click(); + await page.locator('[data-files-create-input]').fill('DragTwo.txt'); + await page.locator('[data-files-create-confirm]').click(); + + await page.locator('[data-file-name="DragOne.md"]').click(); + await page.locator('[data-file-name="DragTwo.txt"]').click({ modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'] }); + await expect(page.locator('.files-item.selected')).toHaveCount(2); + + await page.evaluate(() => { + const source = document.querySelector('[data-file-name="DragOne.md"]'); + const target = document.querySelector('[data-file-name="DropTarget"]'); + const dt = new DataTransfer(); + source.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt })); + target.dispatchEvent(new DragEvent('dragover', { bubbles: true, dataTransfer: dt })); + target.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: dt })); + }); + + await expect(page.locator('[data-file-name="DragOne.md"]')).toHaveCount(0); + await expect(page.locator('[data-file-name="DragTwo.txt"]')).toHaveCount(0); + await page.locator('[data-file-name="DropTarget"]').dblclick(); + await expect(page.locator('[data-file-name="DragOne.md"]')).toBeVisible(); + await expect(page.locator('[data-file-name="DragTwo.txt"]')).toBeVisible(); + }); + + test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => { + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); + await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); + + await page.locator('[data-file-name="Notes"]').dblclick(); + await expect(page.locator('.files-breadcrumb')).toContainText('Notes'); + + await page.locator('.files-root').focus(); + await page.keyboard.press('Alt+ArrowLeft'); + await expect(page.locator('.files-breadcrumb')).not.toContainText('Notes'); + + await page.keyboard.press('Alt+ArrowRight'); + await expect(page.locator('.files-breadcrumb')).toContainText('Notes'); + + await page.dispatchEvent('.files-root', 'mouseup', { button: 8, buttons: 128, bubbles: true, cancelable: true }); + await expect(page.locator('.files-breadcrumb')).not.toContainText('Notes'); + + await page.dispatchEvent('.files-root', 'mouseup', { button: 9, buttons: 256, bubbles: true, cancelable: true }); + await expect(page.locator('.files-breadcrumb')).toContainText('Notes'); + + await page.locator('.wt-label').filter({ hasText: 'Test' }).click(); + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); + await expect(page.locator('.files-breadcrumb')).toContainText('Notes'); + }); + + test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => { + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); + await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 }); + + await page.locator('[data-file-name="Notes"]').dblclick(); + await expect(page.locator('.files-breadcrumb')).toContainText('Notes'); + + await page.locator('[data-file-name="Overview.md"]').dblclick(); + await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 }); + + await page.dispatchEvent('body', 'mousedown', { button: 3, bubbles: true, cancelable: true }); + await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.files-breadcrumb')).toContainText('Notes'); + await expect(page.locator('[data-file-name="Overview.md"]')).toBeVisible(); + + await page.locator('[data-file-name="Overview.md"]').dblclick(); + await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 }); + + await page.waitForTimeout(150); + await page.evaluate(() => { + document.body.dispatchEvent(new PointerEvent('pointerdown', { + button: 3, + buttons: 8, + bubbles: true, + cancelable: true, + pointerType: 'mouse' + })); + }); + await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.files-breadcrumb')).toContainText('Notes'); + + await page.locator('[data-file-name="Overview.md"]').dblclick(); + await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 }); + + await page.locator('.workbench-header .close-btn[aria-label="Close"]').click(); + await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.files-breadcrumb')).toContainText('Notes'); + }); + test('open .txt via workbench from files context shows default-editor', async ({ page }) => { await page.evaluate(async () => { const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', { diff --git a/frontend/e2e/plugin-manager-layout.spec.js b/frontend/e2e/plugin-manager-layout.spec.js index 1ff6958..3ce5c3f 100644 --- a/frontend/e2e/plugin-manager-layout.spec.js +++ b/frontend/e2e/plugin-manager-layout.spec.js @@ -82,6 +82,18 @@ test.describe('E: Plugin Manager layout', () => { await expect(selected).toHaveText('Test'); }); + test('workspace tools render as tabs with Files as one tab', async ({ page }) => { + await page.locator('.wt-label').filter({ hasText: 'Project' }).click(); + + const tabs = page.locator('.workspace-tabs'); + await expect(tabs).toBeVisible({ timeout: 10000 }); + const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' }); + await expect(filesTab).toBeVisible(); + await expect(filesTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.workspace-tool')).toHaveCount(0); + await expect(page.locator('.files-root')).toBeVisible(); + }); + test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => { await page.locator('button[title="New workspace"]').click(); await page.locator('.wt-create input').fill('ClientA'); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5367d47..c618d9d 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -23,11 +23,111 @@ let workspaceNodes = []; let selectedWorkspaceName = ''; + let navigationStack = []; + let navigationIndex = -1; + let applyingNavigation = false; + let lastMouseHistoryDirection = ''; + let lastMouseHistoryAt = 0; function flog(msg) { App.WriteFrontendLog('App', msg); } + function currentSnapshot() { + return { + currentView, + activeView, + activeViewPluginId, + activeSettingsPluginId, + activeSettingsPanelId, + openedResource, + selectedWorkspaceName, + }; + } + + function sameSnapshot(a, b) { + return JSON.stringify(a) === JSON.stringify(b); + } + + function pushNavigation(snapshot = currentSnapshot()) { + if (applyingNavigation) return; + if (navigationIndex >= 0 && sameSnapshot(navigationStack[navigationIndex], snapshot)) return; + if (navigationIndex < navigationStack.length - 1) { + navigationStack = navigationStack.slice(0, navigationIndex + 1); + } + navigationStack = [...navigationStack, snapshot]; + navigationIndex = navigationStack.length - 1; + } + + function applySnapshot(snapshot) { + applyingNavigation = true; + currentView = snapshot.currentView; + activeView = snapshot.activeView; + activeViewPluginId = snapshot.activeViewPluginId; + activeSettingsPluginId = snapshot.activeSettingsPluginId; + activeSettingsPanelId = snapshot.activeSettingsPanelId; + openedResource = snapshot.openedResource; + selectedWorkspaceName = snapshot.selectedWorkspaceName; + applyingNavigation = false; + } + + function navigateBack() { + if (navigationIndex <= 0) return false; + navigationIndex -= 1; + applySnapshot(navigationStack[navigationIndex]); + return true; + } + + function navigateForward() { + if (navigationIndex >= navigationStack.length - 1) return false; + navigationIndex += 1; + applySnapshot(navigationStack[navigationIndex]); + return true; + } + + function mouseHistoryDirection(event) { + if (currentView === 'workspace') return ''; + if (event.button === 3 || event.button === 8 || event.buttons === 8 || event.buttons === 128 || event.which === 8) return 'back'; + if (event.button === 4 || event.button === 9 || event.buttons === 16 || event.buttons === 256 || event.which === 9) return 'forward'; + return ''; + } + + function keyHistoryDirection(event) { + if (currentView === 'workspace') return ''; + const key = event.key || ''; + if (event.altKey && key === 'ArrowLeft') return 'back'; + if (event.altKey && key === 'ArrowRight') return 'forward'; + if (key === 'BrowserBack' || key === 'XF86Back') return 'back'; + if (key === 'BrowserForward' || key === 'XF86Forward') return 'forward'; + if (event.keyCode === 166) return 'back'; + if (event.keyCode === 167) return 'forward'; + return ''; + } + + function handleHistoryRequest(direction, event) { + if (!direction || event?.defaultPrevented) return; + if (event?.type === 'mousedown' || event?.type === 'mouseup' || event?.type === 'auxclick' || event?.type === 'pointerdown') { + const now = Date.now(); + if (direction === lastMouseHistoryDirection && now - lastMouseHistoryAt < 120) return; + lastMouseHistoryDirection = direction; + lastMouseHistoryAt = now; + debug.log('[App] mouse history event', { + type: event.type, + direction, + button: event.button, + buttons: event.buttons, + which: event.which, + pointerType: event.pointerType || '', + currentView, + }); + } + const moved = direction === 'back' ? navigateBack() : navigateForward(); + if (moved && event) { + event.preventDefault(); + event.stopPropagation(); + } + } + async function checkVault() { debug.log('[App] checkVault: START'); flog('checkVault: START'); @@ -73,6 +173,7 @@ function onNav(e) { debug.log('[App] onNav:', e.detail.viewId); currentView = e.detail.viewId; + pushNavigation(); } function onOpenView(e) { @@ -80,6 +181,7 @@ activeView = e.detail.viewId; activeViewPluginId = e.detail.pluginId || ''; currentView = 'plugin-view'; + pushNavigation(); } function onOpenSettings(e) { @@ -87,12 +189,14 @@ activeSettingsPluginId = e.detail.pluginId; activeSettingsPanelId = e.detail.panelId || ''; currentView = 'plugin-manager'; + pushNavigation(); } function onWorkbenchOpened(e) { debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId); openedResource = e.detail; currentView = 'workbench'; + pushNavigation(); } function onWorkspaceSelected(e) { @@ -101,6 +205,7 @@ workspaceNodes = e.detail?.nodes || workspaceNodes; if (selectedWorkspaceName) { currentView = 'workspace'; + pushNavigation(); } } @@ -110,6 +215,31 @@ activeSettingsPanelId = ''; } + function onNavigateBack(e) { + if (navigateBack()) e?.preventDefault?.(); + } + + function onNavigateForward(e) { + if (navigateForward()) e?.preventDefault?.(); + } + + function onCloseWorkbench(e) { + if (currentView !== 'workbench') return; + if (!navigateBack() && selectedWorkspaceName) { + currentView = 'workspace'; + pushNavigation(); + } + e?.preventDefault?.(); + } + + function onGlobalKeydown(e) { + handleHistoryRequest(keyHistoryDirection(e), e); + } + + function onGlobalMouse(e) { + handleHistoryRequest(mouseHistoryDirection(e), e); + } + // Listen for events if (typeof window !== 'undefined') { window.addEventListener('verstak:vault-opened', onVaultOpened); @@ -119,9 +249,20 @@ window.addEventListener('verstak:close-settings', onCloseSettings); window.addEventListener('verstak:workbench-opened', onWorkbenchOpened); window.addEventListener('verstak:workspace-selected', onWorkspaceSelected); + window.addEventListener('verstak:navigate-back', onNavigateBack); + window.addEventListener('verstak:navigate-forward', onNavigateForward); + window.addEventListener('verstak:close-workbench', onCloseWorkbench); + window.addEventListener('keydown', onGlobalKeydown); + window.addEventListener('pointerdown', onGlobalMouse, true); + window.addEventListener('mousedown', onGlobalMouse, true); + window.addEventListener('mouseup', onGlobalMouse, true); + window.addEventListener('auxclick', onGlobalMouse, true); } - onMount(() => { checkVault(); }); + onMount(async () => { + await checkVault(); + pushNavigation(); + }); {#if loading} diff --git a/frontend/src/lib/shell/WorkbenchHost.svelte b/frontend/src/lib/shell/WorkbenchHost.svelte index 016272b..37ef971 100644 --- a/frontend/src/lib/shell/WorkbenchHost.svelte +++ b/frontend/src/lib/shell/WorkbenchHost.svelte @@ -1,5 +1,6 @@
@@ -26,6 +31,9 @@
{resourcePath} no-provider +

No viewer/editor available

@@ -35,6 +43,9 @@
{resourcePath} {providerId} +
{#key mountKey} @@ -82,6 +93,24 @@ white-space: nowrap; } + .close-btn { + width: 2rem; + height: 2rem; + min-height: 0; + padding: 0; + border: 1px solid #1a3a5c; + border-radius: 4px; + background: #0f3460; + color: #a0a0b8; + flex-shrink: 0; + cursor: pointer; + } + + .close-btn:hover { + color: #e0e0f0; + background: #1a4a7a; + } + .workbench-provider { color: #4ecca3; font-size: 0.75rem; diff --git a/frontend/src/lib/shell/WorkspaceHost.svelte b/frontend/src/lib/shell/WorkspaceHost.svelte index 6eb9fe2..538a417 100644 --- a/frontend/src/lib/shell/WorkspaceHost.svelte +++ b/frontend/src/lib/shell/WorkspaceHost.svelte @@ -8,11 +8,22 @@ let contributions = {}; let plugins = []; let workspaceTools = []; + let activeToolKey = ''; $: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; + $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; + $: workspaceType = selectedWorkspace?.type || 'workspace'; + $: activeTool = workspaceTools.find(tool => toolKey(tool) === activeToolKey) || workspaceTools[0] || null; + $: if (workspaceTools.length > 0 && (!activeToolKey || !workspaceTools.some(tool => toolKey(tool) === activeToolKey))) { + activeToolKey = toolKey(workspaceTools[0]); + } $: if (selectedWorkspaceName) loadTools(); + function toolKey(tool) { + return `${tool?.pluginId || ''}:${tool?.id || ''}`; + } + async function loadTools() { try { const [c, p] = await Promise.all([ @@ -36,28 +47,34 @@
{#if selectedWorkspace}
- {selectedWorkspace.title} - {selectedWorkspace.type} + {workspaceTitle} + {workspaceType}
{#if workspaceTools.length > 0} -
+
{#each workspaceTools as tool (tool.id + tool.pluginId)} -
-
- {tool.title || tool.id} - {tool.pluginId} -
-
- -
-
+ {/each}
+
+ {#if activeTool} + + {/if} +
{:else}

No workspace tools available

@@ -104,45 +121,44 @@ background: #1a2a3a; } - .workspace-tools { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 0.5rem; - } - - .workspace-tool { - border: 1px solid #16213e; - border-radius: 6px; - margin-bottom: 0.5rem; - overflow: hidden; - } - - .tool-header { + .workspace-tabs { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.75rem; + gap: 0.25rem; + padding: 0.35rem 0.75rem 0; background: #12122a; border-bottom: 1px solid #16213e; + flex-shrink: 0; } - .tool-title { - color: #e0e0f0; + .workspace-tabs button { + min-height: 2rem; + padding: 0.35rem 0.8rem; + border: 1px solid transparent; + border-bottom: none; + border-radius: 6px 6px 0 0; + background: transparent; + color: #8b8ba8; + cursor: pointer; + font: inherit; font-size: 0.8rem; - font-weight: 600; } - .tool-plugin { - color: #666; - font-size: 0.65rem; - margin-left: auto; + .workspace-tabs button:hover { + color: #e0e0f0; + background: rgba(15, 52, 96, 0.4); } - .tool-content { - min-height: 300px; - max-height: 60vh; - overflow: auto; + .workspace-tabs button.active { + color: #4ecca3; + background: #1a1a2e; + border-color: #16213e; + } + + .workspace-tool-content { + flex: 1; + min-height: 0; + overflow: hidden; } .workspace-empty { diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index d9f9bf1..4e2f18a 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -522,12 +522,16 @@ function filesPluginBundle() { return '(' + function () { + var SVG = ''; + var FOLDER_SVG = ''; function e(tag, attrs, children) { var node = document.createElement(tag); attrs = attrs || {}; Object.keys(attrs).forEach(function (key) { if (key === 'className') node.className = attrs[key]; else if (key.indexOf('on') === 0) node.addEventListener(key.slice(2).toLowerCase(), attrs[key]); + else if (key === 'innerHTML') node.innerHTML = attrs[key]; + else if (key === 'style' && typeof attrs[key] === 'object') Object.assign(node.style, attrs[key]); else node.setAttribute(key, attrs[key]); }); (children || []).forEach(function (child) { if (child) node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); }); @@ -535,10 +539,16 @@ } function clean(path) { return String(path || '').split('/').filter(Boolean).join('/'); } function parent(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? '' : path.slice(0, i); } - function base(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? path : path.slice(i + 1); } function ext(name) { var i = String(name || '').lastIndexOf('.'); return i > 0 ? name.slice(i + 1).toLowerCase() : ''; } + function base(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? path : path.slice(i + 1); } var FilesView = { mount: function (c, p, api) { + if (!document.getElementById('mock-files-styles')) { + var style = document.createElement('style'); + style.id = 'mock-files-styles'; + style.textContent = '.files-root{display:flex;flex-direction:column;height:100%;min-height:0;background:#0d0d1a;color:#e0e0e0;outline:0}.files-toolbar{display:flex;align-items:center;gap:.4rem;padding:.5rem .75rem;background:#12122a;border-bottom:1px solid #16213e;flex-wrap:wrap}.files-toolbar-btn,.files-row-btn{display:inline-flex;align-items:center;justify-content:center;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}.files-toolbar-btn{width:2rem;height:2rem}.files-row-btn{width:1.75rem;height:1.75rem}.files-toolbar-btn svg,.files-row-btn svg{width:16px;height:16px}.files-breadcrumb{flex:1;min-width:150px;color:#8b8ba8}.files-breadcrumb-item{color:#4ecca3;cursor:pointer}.files-breadcrumb-current{color:#ddd}.files-filter,.files-sort,.files-create-input,.files-rename-input{font-size:.78rem;padding:.32rem .5rem;border:1px solid #333;border-radius:4px;background:#0d0d1a;color:#e0e0e0}.files-sort{appearance:none;background-color:#0d0d1a;padding-right:1rem}.files-list{flex:1;overflow:auto}.files-header,.files-item{display:grid;grid-template-columns:minmax(160px,1fr) 90px 90px 150px 160px;align-items:center;gap:.5rem;padding:.38rem .75rem;border-bottom:1px solid rgba(22,33,62,.55)}.files-header{background:#101028;color:#8b8ba8;font-size:.7rem;text-transform:uppercase}.files-item:hover{background:#17172d}.files-item.selected{background:#1a2a3a}.files-namecell{display:flex;align-items:center;gap:.5rem;min-width:0}.files-item-icon{width:1.25rem;color:#8b8ba8}.files-item-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.files-item-meta{font-size:.74rem;color:#777;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.files-row-actions{display:flex;justify-content:flex-end;gap:.35rem}.files-panel{display:flex;gap:.5rem;padding:.5rem .75rem;border-top:1px solid #16213e;background:#12122a}.files-create-input,.files-rename-input{flex:1}.files-ctx-menu{position:fixed;z-index:9999;min-width:170px;background:#1a1a2e;border:1px solid #333;border-radius:6px;padding:6px 0;box-shadow:0 8px 24px rgba(0,0,0,.5);font-size:.84rem;color:#e0e0e0}.files-ctx-menu-item{display:flex;align-items:center;gap:.5rem;padding:6px 16px;cursor:pointer}.files-ctx-menu-item:hover{background:#2a2a4e}.files-ctx-menu-item svg{width:14px;height:14px}.files-ctx-menu-sep{height:1px;background:#333;margin:4px 8px}.files-drag-over{outline:2px dashed #4ecca3;outline-offset:-2px}'; + document.head.appendChild(style); + } c.innerHTML = ''; c.className = 'files-root'; c.setAttribute('tabindex', '0'); @@ -546,27 +556,40 @@ var n = p && p.workspaceNode; var root = clean((p && (p.workspaceRootPath || (n && (n.rootPath || n.name || n.id)))) || ''); var workspaceName = root || 'Workspace'; - var current = ''; + window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {}; + var historyKey = root || workspaceName; + var savedHistory = window.__filesHistoryByWorkspace[historyKey] || { stack: [''], index: 0, currentPath: '' }; + var current = clean(savedHistory.currentPath || ''); + var history = savedHistory.stack && savedHistory.stack.length ? savedHistory.stack.map(clean) : [current]; + var historyIndex = Math.max(0, Math.min(savedHistory.index || 0, history.length - 1)); var entries = []; - var selected = ''; + var selected = {}; + var lastClicked = ''; var filter = ''; var sort = 'folder-name'; var createMode = ''; var renaming = null; function scoped(local) { local = clean(local); return root ? (local ? root + '/' + local : root) : local; } function local(full) { full = clean(full); return root && full.indexOf(root + '/') === 0 ? full.slice(root.length + 1) : full === root ? '' : full; } + function saveHistory() { window.__filesHistoryByWorkspace[historyKey] = { stack: history.slice(), index: historyIndex, currentPath: current }; } var toolbar = e('div', { className: 'files-toolbar' }, []); var breadcrumb = e('div', { className: 'files-breadcrumb' }, []); - function btn(label, action, fn) { return e('button', { className: 'files-toolbar-btn', 'data-files-action': action, onClick: fn }, [label]); } + function btn(title, action, fn) { return e('button', { className: 'files-toolbar-btn', 'data-files-action': action, title: title, 'aria-label': title, innerHTML: SVG, onClick: fn }, []); } + function rowBtn(title, action, fn) { return e('button', { className: 'files-row-btn', 'data-files-action': action, title: title, 'aria-label': title, innerHTML: SVG, onClick: fn }, []); } toolbar.appendChild(breadcrumb); + toolbar.appendChild(btn('Back', 'back', goBack)); + toolbar.appendChild(btn('Forward', 'forward', goForward)); toolbar.appendChild(btn('Up', 'up', function () { if (current) nav(parent(current)); })); toolbar.appendChild(btn('Refresh', 'refresh', load)); - toolbar.appendChild(btn('+ Folder', 'new-folder', function () { startCreate('folder'); })); - toolbar.appendChild(btn('+ Markdown', 'new-markdown', function () { startCreate('markdown'); })); - toolbar.appendChild(btn('+ Text', 'new-text', function () { startCreate('text'); })); - toolbar.appendChild(btn('Open', 'open', function () { open(entryByPath(selected)); })); - toolbar.appendChild(btn('Rename', 'rename', function () { startRename(entryByPath(selected)); })); - toolbar.appendChild(btn('Trash', 'trash', function () { trash(entryByPath(selected)); })); + toolbar.appendChild(btn('New folder', 'new-folder', function () { startCreate('folder'); })); + toolbar.appendChild(btn('New markdown file', 'new-markdown', function () { startCreate('markdown'); })); + toolbar.appendChild(btn('New text file', 'new-text', function () { startCreate('text'); })); + toolbar.appendChild(btn('Open', 'open', function () { open(firstSelected()); })); + toolbar.appendChild(btn('Rename', 'rename', function () { startRename(firstSelected()); })); + toolbar.appendChild(btn('Move to trash', 'trash', function () { trashSelection(); })); + toolbar.appendChild(btn('Cut', 'cut', cutSelection)); + toolbar.appendChild(btn('Copy', 'copy', copySelection)); + toolbar.appendChild(btn('Paste', 'paste', paste)); var filterInput = e('input', { className: 'files-filter', 'data-files-filter': '', placeholder: 'Filter current folder' }, []); filterInput.addEventListener('input', function () { filter = filterInput.value.toLowerCase(); render(); }); toolbar.appendChild(filterInput); @@ -595,6 +618,8 @@ renamePanel.appendChild(e('button', { className: 'files-toolbar-btn', onClick: function () { renamePanel.style.display = 'none'; } }, ['Cancel'])); c.appendChild(renamePanel); function entryByPath(path) { return entries.find(function (item) { return item.relativePath === path; }) || null; } + function selectedEntries() { return Object.keys(selected).map(entryByPath).filter(Boolean); } + function firstSelected() { return selectedEntries()[0] || null; } function updateBreadcrumb() { breadcrumb.innerHTML = ''; breadcrumb.appendChild(e('span', { className: 'files-breadcrumb-item', onClick: function () { nav(''); } }, [workspaceName])); @@ -613,18 +638,59 @@ updateBreadcrumb(); list.innerHTML = ''; list.appendChild(e('div', { className: 'files-header' }, [e('span', {}, ['Name']), e('span', {}, ['Type']), e('span', {}, ['Size']), e('span', {}, ['Modified']), e('span', {}, ['Actions'])])); - visible().forEach(function (item) { - var row = e('div', { className: 'files-item' + (selected === item.relativePath ? ' selected' : ''), 'data-file-name': item.name, 'data-file-type': item.type, 'data-file-path': item.relativePath, onClick: function () { selected = item.relativePath; render(); }, onDblclick: function () { open(item); } }, []); - row.appendChild(e('span', { className: 'files-item-name' }, [item.name])); + var shown = visible(); + shown.forEach(function (item) { + var row = e('div', { + className: 'files-item' + (selected[item.relativePath] ? ' selected' : ''), + 'data-file-name': item.name, + 'data-file-type': item.type, + 'data-file-path': item.relativePath, + draggable: 'true', + onClick: function (ev) { select(item, ev); }, + onDblclick: function () { open(item); }, + onDragstart: function (ev) { + if (!selected[item.relativePath]) { selected = {}; selected[item.relativePath] = true; } + ev.dataTransfer.setData('application/files-paths', JSON.stringify(Object.keys(selected))); + ev.dataTransfer.effectAllowed = 'move'; + } + }, []); + row.appendChild(e('span', { className: 'files-namecell' }, [e('span', { className: 'files-item-icon', innerHTML: item.type === 'folder' ? FOLDER_SVG : SVG }, []), e('span', { className: 'files-item-name' }, [item.name])])); row.appendChild(e('span', { className: 'files-item-meta' }, [item.type === 'folder' ? 'folder' : (item.extension || ext(item.name) || 'file')])); row.appendChild(e('span', { className: 'files-item-meta' }, [item.size ? String(item.size) : ''])); row.appendChild(e('span', { className: 'files-item-meta' }, [item.modifiedAt || ''])); - row.appendChild(e('span', { className: 'files-row-actions' }, [e('button', { className: 'files-row-btn', onClick: function (ev) { ev.stopPropagation(); open(item); } }, ['Open']), e('button', { className: 'files-row-btn', onClick: function (ev) { ev.stopPropagation(); startRename(item); } }, ['Rename']), e('button', { className: 'files-row-btn', onClick: function (ev) { ev.stopPropagation(); trash(item); } }, ['Trash'])])); + row.appendChild(e('span', { className: 'files-row-actions' }, [rowBtn('Open', 'row-open', function (ev) { ev.stopPropagation(); open(item); }), rowBtn('Rename', 'row-rename', function (ev) { ev.stopPropagation(); startRename(item); }), rowBtn('Move to trash', 'row-trash', function (ev) { ev.stopPropagation(); trash(item); })])); list.appendChild(row); }); } - function load() { selected = ''; api.files.list(scoped(current)).then(function (result) { entries = result || []; render(); }).catch(function (err) { list.textContent = 'Error: ' + (err.message || err); }); } - function nav(path) { current = clean(path); load(); } + function select(item, ev) { + if (ev && (ev.ctrlKey || ev.metaKey)) { + if (selected[item.relativePath]) delete selected[item.relativePath]; else selected[item.relativePath] = true; + } else if (ev && ev.shiftKey && lastClicked) { + var shown = visible(); + var a = shown.findIndex(function (x) { return x.relativePath === lastClicked; }); + var b = shown.findIndex(function (x) { return x.relativePath === item.relativePath; }); + if (a >= 0 && b >= 0) { + selected = {}; + for (var i = Math.min(a, b); i <= Math.max(a, b); i++) selected[shown[i].relativePath] = true; + } + } else { + selected = {}; selected[item.relativePath] = true; + } + lastClicked = item.relativePath; + render(); + } + function load() { selected = {}; api.files.list(scoped(current)).then(function (result) { entries = result || []; render(); }).catch(function (err) { list.textContent = 'Error: ' + (err.message || err); }); } + function nav(path, push) { + current = clean(path); + if (push !== false) { + if (historyIndex < history.length - 1) history = history.slice(0, historyIndex + 1); + if (history[history.length - 1] !== current) { history.push(current); historyIndex = history.length - 1; } + } + saveHistory(); + load(); + } + function goBack() { if (historyIndex <= 0) return; historyIndex -= 1; current = history[historyIndex]; saveHistory(); load(); } + function goForward() { if (historyIndex >= history.length - 1) return; historyIndex += 1; current = history[historyIndex]; saveHistory(); load(); } function open(item) { if (!item) return; if (item.type === 'folder') { nav(local(item.relativePath)); return; } @@ -651,11 +717,127 @@ api.files.move(renaming.relativePath, to, { overwrite: false }).then(function () { renamePanel.style.display = 'none'; renaming = null; load(); }); } function trash(item) { if (!item || !window.confirm('Move "' + item.name + '" to trash?')) return; api.files.trash(item.relativePath).then(load); } + function trashSelection() { var items = selectedEntries(); if (items.length === 1) return trash(items[0]); if (!items.length || !window.confirm('Move ' + items.length + ' items to trash?')) return; Promise.all(items.map(function (item) { return api.files.trash(item.relativePath); })).then(load); } + function setClipboard(action, items) { if (!items.length) return; window.__filesClipboard = { action: action, workspaceRoot: root, items: items.map(function (item) { return { path: item.relativePath, name: item.name, type: item.type }; }) }; } + function cutSelection() { setClipboard('cut', selectedEntries()); } + function copySelection() { setClipboard('copy', selectedEntries().filter(function (item) { return item.type !== 'folder'; })); } + function uniqueName(name, occupied) { if (!occupied[name]) return name; var dot = name.lastIndexOf('.'); var b = dot > 0 ? name.slice(0, dot) : name; var x = dot > 0 ? name.slice(dot) : ''; for (var i = 2; i < 100; i++) { var c = b + ' (' + i + ')' + x; if (!occupied[c]) return c; } return b + ' (' + Date.now() + ')' + x; } + function paste() { + var clip = window.__filesClipboard; + if (!clip || !clip.items || !clip.items.length) return; + var dest = scoped(current); + var occupied = {}; + entries.forEach(function (item) { occupied[item.name] = true; }); + Promise.all(clip.items.map(function (item) { + var name = uniqueName(item.name, occupied); + occupied[name] = true; + var to = dest ? dest + '/' + name : name; + if (clip.action === 'cut') return api.files.move(item.path, to, { overwrite: false }); + return api.files.readText(item.path).then(function (text) { return api.files.writeText(to, text, { createIfMissing: true, overwrite: false }); }); + })).then(function () { if (clip.action === 'cut') window.__filesClipboard = null; load(); }); + } + var menu = e('div', { className: 'files-ctx-menu', style: { display: 'none' } }, []); + document.body.appendChild(menu); + function menuItem(label, action, fn) { return e('div', { className: 'files-ctx-menu-item', 'data-files-menu-action': action, onClick: function (ev) { ev.stopPropagation(); menu.style.display = 'none'; fn(); } }, [e('span', { innerHTML: SVG }, []), label]); } + function showMenu(x, y, item) { + menu.innerHTML = ''; + if (item) { + if (!selected[item.relativePath]) { selected = {}; selected[item.relativePath] = true; render(); } + menu.appendChild(menuItem('Open', 'open', function () { open(item); })); + menu.appendChild(menuItem('Rename', 'rename', function () { startRename(item); })); + menu.appendChild(menuItem('Cut', 'cut', cutSelection)); + menu.appendChild(menuItem('Copy', 'copy', copySelection)); + menu.appendChild(menuItem('Trash', 'trash', trashSelection)); + } else { + menu.appendChild(menuItem('New Folder', 'new-folder', function () { startCreate('folder'); })); + menu.appendChild(menuItem('New Markdown', 'new-markdown', function () { startCreate('markdown'); })); + menu.appendChild(menuItem('New Text', 'new-text', function () { startCreate('text'); })); + if (window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length) menu.appendChild(menuItem('Paste', 'paste', paste)); + } + menu.style.display = 'block'; menu.style.left = x + 'px'; menu.style.top = y + 'px'; + } createInput.addEventListener('keydown', function (ev) { if (ev.key === 'Enter') confirmCreate(); }); renameInput.addEventListener('keydown', function (ev) { if (ev.key === 'Enter') confirmRename(); }); + list.addEventListener('contextmenu', function (ev) { ev.preventDefault(); var row = ev.target.closest('.files-item'); showMenu(ev.clientX, ev.clientY, row ? entryByPath(row.getAttribute('data-file-path')) : null); }); + list.addEventListener('dragover', function (ev) { ev.preventDefault(); var row = ev.target.closest('.files-item'); if (row) row.classList.add('files-drag-over'); }); + list.addEventListener('dragleave', function (ev) { var row = ev.target.closest('.files-item'); if (row) row.classList.remove('files-drag-over'); }); + list.addEventListener('drop', function (ev) { + ev.preventDefault(); + Array.from(list.querySelectorAll('.files-drag-over')).forEach(function (row) { row.classList.remove('files-drag-over'); }); + var raw = ev.dataTransfer.getData('application/files-paths'); + if (!raw) return; + var paths = JSON.parse(raw); + var row = ev.target.closest('.files-item'); + var target = row && row.getAttribute('data-file-type') === 'folder' ? row.getAttribute('data-file-path') : scoped(current); + Promise.all(paths.map(function (path) { return api.files.move(path, target + '/' + base(path), { overwrite: false }); })).then(load); + }); + var lastMouseHistoryAt = 0; + var lastMouseHistoryButton = 0; + function mouseHistoryButton(ev) { + if (ev.button === 3 || ev.button === 8 || ev.buttons === 8 || ev.buttons === 128 || ev.which === 8) return 'back'; + if (ev.button === 4 || ev.button === 9 || ev.buttons === 16 || ev.buttons === 256 || ev.which === 9) return 'forward'; + return ''; + } + function mouseHistory(ev) { + var button = mouseHistoryButton(ev); + if (!button) return; + ev.preventDefault(); + ev.stopPropagation(); + var now = Date.now(); + if (button === lastMouseHistoryButton && now - lastMouseHistoryAt < 120) return; + lastMouseHistoryButton = button; + lastMouseHistoryAt = now; + if (button === 'back') goBack(); + else goForward(); + } + function keyHistory(ev) { + if (ev.defaultPrevented) return; + if (ev.target && ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].indexOf(ev.target.tagName) !== -1) return; + var key = ev.key || ''; + var ctrl = ev.ctrlKey || ev.metaKey; + var direction = ''; + if (key === 'ArrowLeft' && ev.altKey) direction = 'back'; + else if (key === 'ArrowRight' && ev.altKey) direction = 'forward'; + else if (key === '[' && ctrl) direction = 'back'; + else if (key === ']' && ctrl) direction = 'forward'; + else if (key === 'BrowserBack' || key === 'XF86Back' || ev.keyCode === 166) direction = 'back'; + else if (key === 'BrowserForward' || key === 'XF86Forward' || ev.keyCode === 167) direction = 'forward'; + if (!direction) return; + ev.preventDefault(); + ev.stopPropagation(); + if (direction === 'back') goBack(); + else goForward(); + } + c.addEventListener('mousedown', mouseHistory, true); + c.addEventListener('pointerdown', mouseHistory, true); + window.addEventListener('pointerdown', mouseHistory, true); + document.addEventListener('pointerdown', mouseHistory, true); + window.addEventListener('mousedown', mouseHistory, true); + document.addEventListener('mousedown', mouseHistory, true); + window.addEventListener('mouseup', mouseHistory, true); + window.addEventListener('auxclick', mouseHistory, true); + window.addEventListener('keydown', keyHistory); + c.addEventListener('keydown', function (ev) { + var ctrl = ev.ctrlKey || ev.metaKey; + if (ctrl && ev.key.toLowerCase() === 'a') { ev.preventDefault(); selected = {}; visible().forEach(function (item) { selected[item.relativePath] = true; }); render(); } + if (ctrl && ev.key.toLowerCase() === 'x') { ev.preventDefault(); cutSelection(); } + if (ctrl && ev.key.toLowerCase() === 'c') { ev.preventDefault(); copySelection(); } + if (ctrl && ev.key.toLowerCase() === 'v') { ev.preventDefault(); paste(); } + }); + c.__filesCleanup = function () { + window.removeEventListener('mousedown', mouseHistory, true); + window.removeEventListener('pointerdown', mouseHistory, true); + document.removeEventListener('pointerdown', mouseHistory, true); + c.removeEventListener('pointerdown', mouseHistory, true); + document.removeEventListener('mousedown', mouseHistory, true); + window.removeEventListener('mouseup', mouseHistory, true); + window.removeEventListener('auxclick', mouseHistory, true); + window.removeEventListener('keydown', keyHistory); + if (menu.parentNode) menu.parentNode.removeChild(menu); + }; load(); }, - unmount: function (c) { c.innerHTML = ''; } + unmount: function (c) { if (c.__filesCleanup) c.__filesCleanup(); c.innerHTML = ''; } }; window.VerstakPluginRegister('verstak.files', { components: { FilesView: FilesView } }); }.toString() + ')();'; diff --git a/frontend/src/lib/ui/Icon.svelte b/frontend/src/lib/ui/Icon.svelte index 1363da9..ef99109 100644 --- a/frontend/src/lib/ui/Icon.svelte +++ b/frontend/src/lib/ui/Icon.svelte @@ -13,6 +13,7 @@ */ import Briefcase from 'lucide-svelte/icons/briefcase'; import ChevronDown from 'lucide-svelte/icons/chevron-down'; + import ChevronLeft from 'lucide-svelte/icons/chevron-left'; import ChevronRight from 'lucide-svelte/icons/chevron-right'; import Circle from 'lucide-svelte/icons/circle'; import FlaskConical from 'lucide-svelte/icons/flask-conical'; @@ -26,6 +27,7 @@ import Shield from 'lucide-svelte/icons/shield'; import TriangleAlert from 'lucide-svelte/icons/triangle-alert'; import Trash2 from 'lucide-svelte/icons/trash-2'; + import X from 'lucide-svelte/icons/x'; export let name = 'dot'; export let size = 16; @@ -34,6 +36,7 @@ const icons = { case: Briefcase, chevronDown: ChevronDown, + chevronLeft: ChevronLeft, chevronRight: ChevronRight, dot: Circle, edit: Pencil, @@ -47,6 +50,7 @@ trash: Trash2, vault: Shield, warning: TriangleAlert, + x: X, }; const aliases = { diff --git a/internal/api/app.go b/internal/api/app.go index 11acf09..7b6823a 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -113,6 +113,7 @@ func (a *App) ensureWorkbench() *coreworkbench.Router { func (a *App) Startup(ctx context.Context) { a.ctx = ctx log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins)) + startMouseMonitor(ctx) } func (a *App) findPlugin(pluginID string) (*plugin.Plugin, error) {