From 477ace6898a03c057f11db966b3036380787e797 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Thu, 13 Aug 2026 10:19:44 +0200 Subject: [PATCH 1/5] Redesign file manager as source workspace --- design-qa.md | 54 ++ static/css/session-workspace.css | 4 + static/css/sftp-file-manager.css | 832 +++++++++++++++++++++++++ static/js/file-workspace-state.js | 109 ++++ static/js/i18n.js | 252 ++++++++ static/js/sftp-file-manager.js | 855 ++++++++++++++++++++++---- templates/index.html | 9 +- tests/e2e/file-workspace.spec.js | 91 +++ tests/e2e/product-captures.spec.js | 45 +- tests/js/file-workspace-state.test.js | 86 +++ tests/js/sftp-transfer-queue.test.js | 239 +++++++ tests/test_key_management_ui.py | 2 +- tests/test_profile_launcher_ui.py | 2 +- 13 files changed, 2451 insertions(+), 129 deletions(-) create mode 100644 design-qa.md create mode 100644 static/js/file-workspace-state.js create mode 100644 tests/e2e/file-workspace.spec.js create mode 100644 tests/js/file-workspace-state.test.js diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..1bbab69 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,54 @@ +# File Workspace Design QA + +## Evidence + +- Source visual truth: `C:\Users\windo\.codex\generated_images\019ff9ca-ce87-7891-a71f-5f92fe020bcf\exec-8ce2eb5f-b5af-4218-aa9a-9c9630284cda.png` +- Browser-rendered implementation: `C:\Users\windo\Coding\webssh\.test-run.tmp\design-qa-implementation-final.png` +- Side-by-side comparison: `C:\Users\windo\Coding\webssh\.test-run.tmp\design-qa-side-by-side-final.png` +- Mobile implementation: `C:\Users\windo\Coding\webssh\.test-run.tmp\design-qa-mobile-2.png` +- Desktop viewport: 1488 x 1054 CSS px, source 1488 x 1054 px, implementation 1488 x 1054 px, density-normalized at 1:1 pixel dimensions. +- Mobile viewport: 390 x 844 CSS px. +- State: dark theme, split layout, source launcher for the right pane, SMB disabled with visible Coming soon status. + +## Findings + +No actionable P0, P1, or P2 finding remains. + +- Typography: the implementation retains the product's existing system-font stack, weights, compact line height, and truncation behavior. The source hierarchy is preserved without adding a runtime font dependency. +- Spacing and layout: full viewport, symmetric dual panes, narrow transfer rail, centered layout/source controls, source tabs, identity rows, file-table columns, and bottom transfer queue follow the source composition. Single-pane mode now expands to the full workspace width. +- Colors and tokens: the existing WebSSH dark tokens drive all surfaces; accent blue, connected green, destructive red, borders, disabled states, and elevation remain consistent with the current product. +- Image quality and assets: the target contains no illustrative imagery. All visible icons use the already vendored Material Icons asset; no placeholder image, inline SVG, emoji, or CSS illustration was introduced. +- Copy and content: source-first labels explain the target pane, protocol, endpoint, security state, local-browser permission, and disabled SMB status. German, English, Vietnamese, French, Spanish, and Chinese key parity passed. +- Accessibility and responsiveness: semantic buttons, tab roles, disabled SMB controls, labels, keyboard Escape/Ctrl+K behavior, visible mobile Coming soon status, and a one-column mobile launcher were verified. The 390 px view has no clipped core control. + +## Full-view comparison evidence + +The same-state side-by-side browser comparison shows the same primary hierarchy: full-screen workspace, dual file surfaces, centered layout controls, contextual source launcher, and persistent transfer area. The implementation intentionally shows empty panes because no fake SSH connection or fake remote listing is rendered in the real app state; populated file rows are covered by the product-capture E2E fixture and existing SFTP data paths. + +## Focused region comparison evidence + +The source launcher and mobile launcher were captured separately because their text, badges, disabled state, and compact rows are too small to judge only from the full-view comparison. Search filtering exposed only the matching SMB row, the SMB row and New SMB button remained disabled, and Coming soon was visible on desktop and mobile. Browser console warnings/errors: none. + +## Comparison history + +1. P1: single-pane content occupied only the left grid track. Fixed by changing the single-mode grid to one full-width track. Post-fix evidence: `design-qa-implementation-3.png`, measured pane width 1465.6 px in a 1488 px viewport. +2. P2: a long saved-host list pushed SMB and browser sources below the visible launcher region. Fixed with a bounded, independently scrollable saved-host group and by keeping SMB and browser source groups visible. Post-fix evidence: `design-qa-implementation-2.png`. +3. P2: layout controls drifted to the right rather than matching the centered source composition. Fixed by centering the layout/source control cluster while leaving close at the far edge. Post-fix evidence: `design-qa-implementation-final.png`. +4. P2: the mobile launcher hid the SMB status text and cramped two creation actions into one row. Fixed by restoring the Coming soon/security row for disabled SMB and stacking creation actions. Post-fix evidence: `design-qa-mobile-2.png`. + +## Primary interactions tested + +- Open File Manager from the product menu. +- Initial source launcher and source search. +- Disabled SMB row and disabled New SMB share action. +- Switch between one and two panes; empty second pane opens its own source launcher. +- Open real SFTP session descriptors in the selected pane. +- Preserve tabs and paths across pane switching and modal close/reopen. +- File selection enables the real server-to-server transfer path. +- Responsive mobile menu and launcher. + +## Follow-up polish + +- P3: the current WebSSH system font renders slightly heavier than the source mock's compact UI type at small sizes. Keeping the existing product typography is preferable to adding a new vendored font for this isolated screen. + +final result: passed diff --git a/static/css/session-workspace.css b/static/css/session-workspace.css index 791c15f..d9c3ee6 100644 --- a/static/css/session-workspace.css +++ b/static/css/session-workspace.css @@ -119,6 +119,10 @@ .fm-embedded-mode #fmPaneTabs, .fm-embedded-mode #fmTransfer, .fm-embedded-mode #fmLeftPane > .fm-pane-header, +.fm-embedded-mode #fmLeftPane > .fm-source-identity, +.fm-embedded-mode #fmLeftPane > .fm-pane-toolbar, +.fm-embedded-mode #fmLeftPane > .fm-file-list-header, +.fm-embedded-mode .fm-transfer-rail, .fm-embedded-mode #fmMobileUpload { display: none !important; } diff --git a/static/css/sftp-file-manager.css b/static/css/sftp-file-manager.css index 0a28f51..5ee46ad 100644 --- a/static/css/sftp-file-manager.css +++ b/static/css/sftp-file-manager.css @@ -1516,3 +1516,835 @@ .fm-queue-list::-webkit-scrollbar-thumb:hover { background: var(--border-hover); } + +/* Full-screen source workspace. The embedded session SFTP view keeps the rules + in session-workspace.css and does not use this mode. */ +#sftpFileManager.fm-workspace-mode { + padding: 0; + align-items: stretch; + justify-content: stretch; + background: var(--bg-primary); + backdrop-filter: none; +} + +#sftpFileManager.fm-workspace-mode .modal-content { + width: 100%; + max-width: none; + height: 100vh; + max-height: none; + margin: 0; + border: 0; + border-radius: 0; + background: var(--bg-primary); + box-shadow: none; +} + +#sftpFileManager.fm-workspace-mode .fm-workspace-header { + position: relative; + min-height: 60px; + padding: 10px 18px; + border-bottom: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-primary) 92%, #000); +} + +.fm-workspace-header h2, +.fm-workspace-controls, +.fm-layout-switch { + display: flex; + align-items: center; +} + +.fm-workspace-header h2 { + gap: 12px; + margin: 0; + font-size: 17px; +} + +.fm-workspace-brand { + font-weight: 750; + letter-spacing: 0.01em; +} + +.fm-workspace-divider { + width: 1px; + height: 24px; + background: var(--border-color); +} + +.fm-workspace-controls { + position: absolute; + left: 50%; + gap: 12px; + margin: 0; + transform: translateX(-50%); +} + +.fm-layout-switch { + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 7px; + background: var(--bg-secondary); +} + +.fm-layout-btn, +.fm-workspace-close, +.fm-source-tab-add, +.fm-source-launcher-close, +.fm-transfer-direction { + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.fm-layout-btn { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 36px; + padding: 0 13px; + border-right: 1px solid var(--border-color); +} + +.fm-layout-btn:last-child { + border-right: 0; +} + +.fm-layout-btn.active { + color: var(--accent-on-text, #fff); + background: var(--accent-primary); +} + +.fm-layout-btn .material-icons, +.fm-workspace-close .material-icons { + font-size: 19px; +} + +.fm-workspace-close { + display: grid; + width: 36px; + height: 36px; + place-items: center; + margin-left: auto; + border-radius: 6px; +} + +.fm-workspace-close:hover, +.fm-source-tab-add:hover, +.fm-source-launcher-close:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +#sftpFileManager.fm-workspace-mode .modal-body { + padding: 10px; + gap: 10px; +} + +#sftpFileManager.fm-workspace-mode .modal-body > .fm-toolbar, +#sftpFileManager.fm-workspace-mode .modal-body > .fm-pane-tabs, +#sftpFileManager.fm-workspace-mode .modal-body > .fm-mobile-upload { + display: none !important; +} + +#sftpFileManager.fm-workspace-mode .fm-panes { + grid-template-columns: minmax(0, 1fr) 52px minmax(0, 1fr); + gap: 0; +} + +#sftpFileManager.fm-workspace-single .fm-transfer-rail, +#sftpFileManager.fm-workspace-single #fmRightPane { + display: none; +} + +#sftpFileManager.fm-workspace-single .fm-panes { + grid-template-columns: minmax(0, 1fr); +} + +#sftpFileManager.fm-workspace-single.fm-single-right #fmLeftPane { + display: none; +} + +#sftpFileManager.fm-workspace-single.fm-single-right #fmRightPane { + display: flex; +} + +#sftpFileManager.fm-workspace-mode .fm-pane { + border: 1px solid var(--border-color); + border-radius: 7px; + background: var(--bg-secondary); + box-shadow: none; +} + +#sftpFileManager.fm-workspace-mode .fm-pane.active { + border-color: color-mix(in srgb, var(--accent-primary) 75%, var(--border-color)); + box-shadow: inset 0 2px 0 var(--accent-primary); +} + +.fm-pane-header { + min-height: 38px; +} + +#sftpFileManager.fm-workspace-mode .fm-pane-header { + display: flex; + align-items: stretch; + padding: 0; + overflow-x: auto; + border-bottom: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-secondary) 92%, #000); +} + +.fm-legacy-source-select { + display: none !important; +} + +.fm-source-tabs { + display: flex; + flex: 1; + min-width: 0; + align-items: stretch; +} + +.fm-source-tab { + display: flex; + min-width: 150px; + max-width: 260px; + border-right: 1px solid var(--border-color); + color: var(--text-secondary); + background: transparent; +} + +.fm-source-tab.active { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.fm-source-tab-activate, +.fm-source-tab-close { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.fm-source-tab-activate { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: 7px; + padding: 0 5px 0 12px; +} + +.fm-source-tab-close { + display: grid; + width: 32px; + place-items: center; + opacity: 0.55; +} + +.fm-source-tab-close:hover { + opacity: 1; +} + +.fm-source-tab-close .material-icons { + font-size: 16px; +} + +.fm-source-tab-label { + overflow: hidden; + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-source-tab-protocol, +.fm-protocol-badge { + padding: 2px 5px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 65%, var(--border-color)); + border-radius: 3px; + color: var(--accent-primary); + font-size: 9px; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.06em; +} + +.fm-connection-dot { + width: 8px; + height: 8px; + flex: 0 0 8px; + border-radius: 50%; + background: var(--success-color); + box-shadow: 0 0 8px color-mix(in srgb, var(--success-color) 45%, transparent); +} + +.fm-source-tab-add { + display: grid; + width: 42px; + flex: 0 0 42px; + place-items: center; + border-left: 1px solid var(--border-color); +} + +.fm-source-identity { + display: flex; + min-height: 39px; + align-items: center; + gap: 10px; + padding: 7px 14px; + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 12px; +} + +.fm-source-identity-name { + color: var(--text-primary); + font-weight: 650; +} + +.fm-source-identity-endpoint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-source-identity-security { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + color: var(--success-color); + white-space: nowrap; +} + +.fm-source-identity-security .material-icons { + font-size: 15px; +} + +.fm-empty-source-button { + display: inline-flex; + align-items: center; + gap: 8px; + border: 0; + background: transparent; + color: var(--accent-primary); + cursor: pointer; +} + +#sftpFileManager.fm-workspace-mode .fm-pane-nav { + min-height: 45px; + padding: 7px 12px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); +} + +#sftpFileManager.fm-workspace-mode .fm-path-input { + min-height: 30px; + border-color: var(--border-color); + border-radius: 5px; + background: color-mix(in srgb, var(--bg-primary) 88%, #000); + font-family: var(--font-mono, monospace); +} + +.fm-pane-toolbar { + display: flex; + min-height: 45px; + align-items: center; + gap: 2px; + padding: 6px 12px; + overflow-x: auto; + border-bottom: 1px solid var(--border-color); +} + +.fm-pane-toolbar button { + display: inline-flex; + min-height: 32px; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 0; + border-right: 1px solid var(--border-color); + background: transparent; + color: var(--text-secondary); + font-size: 12px; + white-space: nowrap; + cursor: pointer; +} + +.fm-pane-toolbar button:last-child { + border-right: 0; +} + +.fm-pane-toolbar button:hover:not(:disabled) { + color: var(--text-primary); + background: var(--bg-hover); +} + +.fm-pane-toolbar button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.fm-pane-toolbar button.is-danger:not(:disabled) { + color: var(--error-color); +} + +.fm-pane-toolbar .material-icons { + font-size: 17px; +} + +.fm-file-list-header, +#sftpFileManager.fm-workspace-mode .fm-file-item { + display: grid; + grid-template-columns: 26px 22px minmax(140px, 1fr) 90px 138px 112px; + align-items: center; + column-gap: 8px; +} + +.fm-file-list-header { + min-height: 35px; + padding: 0 12px; + border-bottom: 1px solid var(--border-color); + color: var(--text-muted); + font-size: 11px; + font-weight: 650; +} + +.fm-file-list-header input { + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--accent-primary); +} + +.fm-file-list-header span:nth-of-type(1) { + grid-column: 3; +} + +#sftpFileManager.fm-workspace-mode .fm-file-list { + background: color-mix(in srgb, var(--bg-secondary) 96%, #000); +} + +#sftpFileManager.fm-workspace-mode .fm-file-item { + min-height: 38px; + padding: 0 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 75%, transparent); + border-radius: 0; +} + +#sftpFileManager.fm-workspace-mode .fm-file-item:hover { + background: var(--bg-hover); +} + +#sftpFileManager.fm-workspace-mode .fm-file-item.selected { + background: color-mix(in srgb, var(--accent-primary) 18%, transparent); + box-shadow: inset 2px 0 var(--accent-primary); +} + +.fm-file-checkbox { + color: var(--text-muted); + font-size: 17px !important; +} + +.fm-file-item.selected .fm-file-checkbox { + color: var(--accent-primary); +} + +#sftpFileManager.fm-workspace-mode .fm-file-icon { + font-size: 18px; +} + +#sftpFileManager.fm-workspace-mode .fm-file-info { + min-width: 0; +} + +#sftpFileManager.fm-workspace-mode .fm-file-name { + overflow: hidden; + color: var(--text-primary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-file-size, +.fm-file-modified, +.fm-file-permissions { + color: var(--text-secondary); + font-family: var(--font-mono, monospace); + font-size: 11px; +} + +.fm-transfer-rail { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-muted); +} + +.fm-transfer-hint { + max-width: 48px; + font-size: 9px; + line-height: 1.3; + text-align: center; +} + +.fm-transfer-direction { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); +} + +.fm-transfer-direction:not(:disabled):hover { + border-color: var(--accent-primary); + color: var(--accent-on-text, #fff); + background: var(--accent-primary); +} + +.fm-transfer-direction:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +#sftpFileManager.fm-workspace-mode .fm-pane-footer { + min-height: 36px; + padding: 0 12px; + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +#sftpFileManager.fm-workspace-mode .fm-queue { + flex: 0 0 auto; + max-height: 205px; + border-radius: 6px; +} + +.fm-source-launcher { + position: absolute; + inset: 60px 0 0; + z-index: 40; + display: none; + align-items: flex-start; + justify-content: flex-end; + padding: 78px 5vw 24px; + background: color-mix(in srgb, var(--bg-primary) 35%, transparent); + backdrop-filter: blur(2px); +} + +.fm-source-launcher.show { + display: flex; +} + +.fm-source-launcher-panel { + display: flex; + width: min(650px, 92vw); + max-height: calc(100vh - 180px); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-hover); + border-radius: 8px; + background: var(--bg-secondary); + box-shadow: 0 18px 55px rgba(0, 0, 0, 0.48); +} + +.fm-source-launcher-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px 10px; +} + +.fm-source-launcher-header h3 { + margin: 0 0 4px; + font-size: 18px; +} + +.fm-source-launcher-header h3 span { + color: var(--accent-primary); +} + +.fm-source-launcher-header p { + margin: 0; + color: var(--text-muted); + font-size: 12px; +} + +.fm-source-launcher-close { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border-radius: 5px; +} + +.fm-source-search, +.fm-source-search-row { + display: flex; + align-items: center; + gap: 8px; + margin: 0 20px 12px; + padding: 0 11px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); +} + +.fm-source-search .material-icons, +.fm-source-search-row .material-icons { + color: var(--text-muted); + font-size: 19px; +} + +.fm-source-search input, +.fm-source-search-row input { + width: 100%; + min-height: 36px; + border: 0; + outline: 0; + background: transparent; + color: var(--text-primary); +} + +.fm-source-search-row kbd { + padding: 2px 5px; + border: 1px solid var(--border-color); + border-radius: 3px; + color: var(--text-muted); + font-size: 9px; +} + +.fm-source-groups { + padding: 0 20px 12px; + overflow-y: auto; +} + +.fm-source-group h4 { + margin: 10px 2px 5px; + color: var(--text-muted); + font-size: 10px; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +#fm-source-group-saved + .fm-source-group-items { + max-height: 184px; + overflow-y: auto; + scrollbar-width: thin; +} + +.fm-source-row { + display: grid; + width: 100%; + min-height: 45px; + grid-template-columns: 24px minmax(130px, 1fr) 52px minmax(145px, 0.8fr); + align-items: center; + gap: 9px; + padding: 6px 10px; + border: 1px solid var(--border-color); + border-bottom: 0; + background: var(--bg-tertiary); + color: var(--text-primary); + text-align: left; + cursor: pointer; +} + +.fm-source-row:first-of-type { + border-radius: 6px 6px 0 0; +} + +.fm-source-row:last-child { + border-bottom: 1px solid var(--border-color); + border-radius: 0 0 6px 6px; +} + +.fm-source-row:hover:not(:disabled) { + border-color: var(--border-hover); + background: var(--bg-hover); +} + +.fm-source-row.is-disabled { + opacity: 0.56; + cursor: not-allowed; +} + +.fm-source-row-icon { + color: var(--success-color); + font-size: 18px; +} + +.fm-source-row-main, +.fm-source-row-status { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.fm-source-row-main strong, +.fm-source-row-status strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-source-row-main small, +.fm-source-row-status small { + display: inline-flex; + align-items: center; + gap: 4px; + overflow: hidden; + color: var(--text-muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-source-row-status strong { + color: var(--success-color); +} + +.fm-source-row-status .material-icons { + font-size: 13px; +} + +.fm-coming-soon { + display: inline-block; + padding: 2px 6px; + border: 1px solid var(--border-hover); + border-radius: 999px; + color: var(--text-secondary); + font-size: 9px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.fm-source-launcher-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + padding: 12px 20px 18px; + border-top: 1px solid var(--border-color); +} + +.fm-source-launcher-actions .btn { + justify-content: center; +} + +.fm-source-launcher-actions .btn > span:last-child { + display: flex; + flex-direction: column; + align-items: flex-start; + line-height: 1.2; +} + +.fm-source-launcher-actions .btn small { + color: var(--text-muted); + font-size: 9px; +} + +.fm-source-no-results { + padding: 28px; + color: var(--text-muted); + text-align: center; +} + +@media (max-width: 1050px) { + .fm-file-list-header, + #sftpFileManager.fm-workspace-mode .fm-file-item { + grid-template-columns: 26px 22px minmax(120px, 1fr) 82px 112px; + } + + .fm-file-list-header span:last-child, + .fm-file-permissions { + display: none; + } + + .fm-pane-toolbar button span:not(.material-icons) { + display: none; + } +} + +@media (max-width: 767px) { + #sftpFileManager.fm-workspace-mode .fm-workspace-header { + min-height: 54px; + padding: 8px 10px; + } + + .fm-workspace-header h2 .fm-workspace-brand, + .fm-workspace-header h2 .fm-workspace-divider, + .fm-layout-btn span:not(.material-icons), + #fmOpenSource span:not(.material-icons) { + display: none; + } + + .fm-workspace-controls { + position: static; + gap: 6px; + margin-left: auto; + transform: none; + } + + #sftpFileManager.fm-workspace-mode .fm-panes { + display: grid !important; + grid-template-columns: minmax(0, 1fr); + } + + #sftpFileManager.fm-workspace-mode .fm-transfer-rail, + #sftpFileManager.fm-workspace-mode .fm-pane:not(.active) { + display: none !important; + } + + #sftpFileManager.fm-workspace-mode .fm-pane.active { + display: flex !important; + } + + .fm-source-launcher { + inset: 54px 0 0; + align-items: stretch; + padding: 8px; + } + + .fm-source-launcher-panel { + width: 100%; + max-height: 100%; + } + + .fm-source-row { + grid-template-columns: 22px minmax(100px, 1fr) 46px; + } + + .fm-source-row-status { + display: none; + } + + .fm-source-row.is-disabled .fm-source-row-status { + display: flex; + grid-column: 2 / 4; + align-items: flex-start; + } + + .fm-source-launcher-actions { + grid-template-columns: minmax(0, 1fr); + } + + .fm-file-list-header, + #sftpFileManager.fm-workspace-mode .fm-file-item { + grid-template-columns: 24px 22px minmax(120px, 1fr) 74px; + } + + .fm-file-list-header span:nth-last-child(-n+2), + .fm-file-modified, + .fm-file-permissions { + display: none; + } +} diff --git a/static/js/file-workspace-state.js b/static/js/file-workspace-state.js new file mode 100644 index 0000000..01b036a --- /dev/null +++ b/static/js/file-workspace-state.js @@ -0,0 +1,109 @@ +(function exposeFileWorkspaceState(globalObject) { + const PANES = new Set(['left', 'right']); + const LAYOUTS = new Set(['single', 'split']); + const SOURCE_FIELDS = [ + 'key', + 'type', + 'label', + 'endpoint', + 'protocol', + 'status', + 'security', + 'profileId', + 'sessionId', + 'connectionId', + ]; + + class FileWorkspaceState { + constructor(createPaneState) { + if (typeof createPaneState !== 'function') { + throw new TypeError('FileWorkspaceState requires a pane-state factory'); + } + this.createPaneState = createPaneState; + this.layout = 'single'; + this.activePane = 'left'; + this.tabSequence = 0; + this.panes = { + left: { tabs: [], activeTabId: null }, + right: { tabs: [], activeTabId: null }, + }; + } + + assertPane(pane) { + if (!PANES.has(pane)) { + throw new TypeError(`Unsupported workspace pane: ${pane}`); + } + } + + normalizeSource(source = {}) { + const normalized = {}; + SOURCE_FIELDS.forEach(field => { + normalized[field] = source[field] ?? null; + }); + return normalized; + } + + getTabs(pane) { + this.assertPane(pane); + return this.panes[pane].tabs; + } + + getActiveTab(pane) { + this.assertPane(pane); + const paneState = this.panes[pane]; + return paneState.tabs.find(tab => tab.id === paneState.activeTabId) || null; + } + + openTab(pane, source, paneState = this.createPaneState()) { + this.assertPane(pane); + const tab = { + id: `workspace-tab-${++this.tabSequence}`, + source: this.normalizeSource(source), + paneState, + }; + this.panes[pane].tabs.push(tab); + this.panes[pane].activeTabId = tab.id; + this.activePane = pane; + return tab; + } + + activateTab(pane, tabId) { + this.assertPane(pane); + const tab = this.panes[pane].tabs.find(item => item.id === tabId); + if (!tab) return null; + this.panes[pane].activeTabId = tab.id; + this.activePane = pane; + return tab; + } + + closeTab(pane, tabId) { + this.assertPane(pane); + const paneState = this.panes[pane]; + const index = paneState.tabs.findIndex(tab => tab.id === tabId); + if (index < 0) return { closed: null, active: this.getActiveTab(pane) }; + + const [closed] = paneState.tabs.splice(index, 1); + if (paneState.activeTabId === tabId) { + const neighbor = paneState.tabs[index] || paneState.tabs[index - 1] || null; + paneState.activeTabId = neighbor?.id || null; + } + return { closed, active: this.getActiveTab(pane) }; + } + + setLayout(layout) { + if (!LAYOUTS.has(layout)) { + throw new TypeError(`Unsupported workspace layout: ${layout}`); + } + this.layout = layout; + return layout; + } + + setActivePane(pane) { + this.assertPane(pane); + this.activePane = pane; + return pane; + } + } + + globalObject.FileWorkspaceState = FileWorkspaceState; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/static/js/i18n.js b/static/js/i18n.js index be9c559..c52f58e 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -197,6 +197,48 @@ const translations = { 'fm.title': 'File Manager', 'fm.refresh': 'Refresh', + 'fm.preview': 'Preview', + 'fm.name': 'Name', + 'fm.size': 'Size', + 'fm.modified': 'Modified', + 'fm.permissions': 'Permissions', + 'fm.workspace.layout': 'File Manager layout', + 'fm.workspace.onePane': '1 pane', + 'fm.workspace.twoPanes': '2 panes', + 'fm.workspace.openSource': 'Open source', + 'fm.workspace.openSourceIn': 'Open source in', + 'fm.workspace.selectAll': 'Select all files', + 'fm.workspace.transferBetween': 'Transfer between panes', + 'fm.workspace.selectFiles': 'Select files', + 'fm.workspace.sourceHint': 'Choose an active SSH session, a saved host, or local browser files.', + 'fm.workspace.searchSources': 'Search sources', + 'fm.workspace.newSftp': 'New SFTP connection', + 'fm.workspace.sftpOverSsh': 'SFTP over SSH', + 'fm.workspace.newSmb': 'New SMB share', + 'fm.workspace.connected': 'Connected', + 'fm.workspace.hostKeyTrusted': 'SSH host key trusted', + 'fm.workspace.available': 'Available', + 'fm.workspace.authenticationRequired': 'Authentication required', + 'fm.workspace.localFiles': 'Local files', + 'fm.workspace.thisBrowser': 'This browser', + 'fm.workspace.unavailable': 'Unavailable', + 'fm.workspace.browserPermission': 'Browser permission required', + 'fm.workspace.smbShare': 'SMB share', + 'fm.workspace.comingSoon': 'Coming soon', + 'fm.workspace.encryptionRequired': 'Encryption required', + 'fm.workspace.activeSessions': 'Active SSH sessions', + 'fm.workspace.savedHosts': 'Saved SSH hosts', + 'fm.workspace.quickConnections': 'SFTP quick connections', + 'fm.workspace.browser': 'This browser', + 'fm.workspace.smbShares': 'SMB shares', + 'fm.workspace.leftPane': 'Left pane', + 'fm.workspace.rightPane': 'Right pane', + 'fm.workspace.noSources': 'No matching sources', + 'fm.workspace.untitledSource': 'Source', + 'fm.workspace.closeSource': 'Close source', + 'fm.workspace.chooseSource': 'Choose source', + 'fm.workspace.leftToRight': 'Move selected left to right', + 'fm.workspace.rightToLeft': 'Move selected right to left', 'editor.edit': 'Edit', 'editor.save': 'Save', 'editor.cancel': 'Cancel', @@ -822,6 +864,48 @@ const translations = { 'fm.title': 'Trình quản lý tệp', 'fm.refresh': 'Làm mới', + 'fm.preview': 'Xem trước', + 'fm.name': 'Tên', + 'fm.size': 'Kích thước', + 'fm.modified': 'Đã sửa đổi', + 'fm.permissions': 'Quyền', + 'fm.workspace.layout': 'Bố cục trình quản lý tệp', + 'fm.workspace.onePane': '1 khung', + 'fm.workspace.twoPanes': '2 khung', + 'fm.workspace.openSource': 'Mở nguồn', + 'fm.workspace.openSourceIn': 'Mở nguồn trong', + 'fm.workspace.selectAll': 'Chọn tất cả tệp', + 'fm.workspace.transferBetween': 'Chuyển giữa các khung', + 'fm.workspace.selectFiles': 'Chọn tệp', + 'fm.workspace.sourceHint': 'Chọn phiên SSH đang hoạt động, máy chủ đã lưu hoặc tệp cục bộ trong trình duyệt.', + 'fm.workspace.searchSources': 'Tìm nguồn', + 'fm.workspace.newSftp': 'Kết nối SFTP mới', + 'fm.workspace.sftpOverSsh': 'SFTP qua SSH', + 'fm.workspace.newSmb': 'Chia sẻ SMB mới', + 'fm.workspace.connected': 'Đã kết nối', + 'fm.workspace.hostKeyTrusted': 'Khóa máy chủ SSH đáng tin cậy', + 'fm.workspace.available': 'Có sẵn', + 'fm.workspace.authenticationRequired': 'Cần xác thực', + 'fm.workspace.localFiles': 'Tệp cục bộ', + 'fm.workspace.thisBrowser': 'Trình duyệt này', + 'fm.workspace.unavailable': 'Không khả dụng', + 'fm.workspace.browserPermission': 'Cần quyền trình duyệt', + 'fm.workspace.smbShare': 'Chia sẻ SMB', + 'fm.workspace.comingSoon': 'Sắp ra mắt', + 'fm.workspace.encryptionRequired': 'Yêu cầu mã hóa', + 'fm.workspace.activeSessions': 'Phiên SSH đang hoạt động', + 'fm.workspace.savedHosts': 'Máy chủ SSH đã lưu', + 'fm.workspace.quickConnections': 'Kết nối nhanh SFTP', + 'fm.workspace.browser': 'Trình duyệt này', + 'fm.workspace.smbShares': 'Chia sẻ SMB', + 'fm.workspace.leftPane': 'Khung trái', + 'fm.workspace.rightPane': 'Khung phải', + 'fm.workspace.noSources': 'Không có nguồn phù hợp', + 'fm.workspace.untitledSource': 'Nguồn', + 'fm.workspace.closeSource': 'Đóng nguồn', + 'fm.workspace.chooseSource': 'Chọn nguồn', + 'fm.workspace.leftToRight': 'Chuyển mục đã chọn từ trái sang phải', + 'fm.workspace.rightToLeft': 'Chuyển mục đã chọn từ phải sang trái', 'editor.edit': 'Chỉnh sửa', 'editor.save': 'Lưu', 'editor.cancel': 'Hủy', @@ -1446,6 +1530,48 @@ const translations = { 'fm.title': 'Dateimanager', 'fm.refresh': 'Aktualisieren', + 'fm.preview': 'Vorschau', + 'fm.name': 'Name', + 'fm.size': 'Größe', + 'fm.modified': 'Geändert', + 'fm.permissions': 'Berechtigungen', + 'fm.workspace.layout': 'Dateimanager-Layout', + 'fm.workspace.onePane': '1 Bereich', + 'fm.workspace.twoPanes': '2 Bereiche', + 'fm.workspace.openSource': 'Quelle öffnen', + 'fm.workspace.openSourceIn': 'Quelle öffnen in', + 'fm.workspace.selectAll': 'Alle Dateien auswählen', + 'fm.workspace.transferBetween': 'Zwischen Bereichen übertragen', + 'fm.workspace.selectFiles': 'Dateien auswählen', + 'fm.workspace.sourceHint': 'Aktive SSH-Sitzung, gespeicherten Host oder lokale Browser-Dateien auswählen.', + 'fm.workspace.searchSources': 'Quellen durchsuchen', + 'fm.workspace.newSftp': 'Neue SFTP-Verbindung', + 'fm.workspace.sftpOverSsh': 'SFTP über SSH', + 'fm.workspace.newSmb': 'Neue SMB-Freigabe', + 'fm.workspace.connected': 'Verbunden', + 'fm.workspace.hostKeyTrusted': 'SSH-Host-Key vertrauenswürdig', + 'fm.workspace.available': 'Verfügbar', + 'fm.workspace.authenticationRequired': 'Authentifizierung erforderlich', + 'fm.workspace.localFiles': 'Lokale Dateien', + 'fm.workspace.thisBrowser': 'Dieser Browser', + 'fm.workspace.unavailable': 'Nicht verfügbar', + 'fm.workspace.browserPermission': 'Browser-Berechtigung erforderlich', + 'fm.workspace.smbShare': 'SMB-Freigabe', + 'fm.workspace.comingSoon': 'Demnächst', + 'fm.workspace.encryptionRequired': 'Verschlüsselung erforderlich', + 'fm.workspace.activeSessions': 'Aktive SSH-Sitzungen', + 'fm.workspace.savedHosts': 'Gespeicherte SSH-Hosts', + 'fm.workspace.quickConnections': 'SFTP-Schnellverbindungen', + 'fm.workspace.browser': 'Dieser Browser', + 'fm.workspace.smbShares': 'SMB-Freigaben', + 'fm.workspace.leftPane': 'Linker Bereich', + 'fm.workspace.rightPane': 'Rechter Bereich', + 'fm.workspace.noSources': 'Keine passenden Quellen', + 'fm.workspace.untitledSource': 'Quelle', + 'fm.workspace.closeSource': 'Quelle schließen', + 'fm.workspace.chooseSource': 'Quelle auswählen', + 'fm.workspace.leftToRight': 'Auswahl von links nach rechts übertragen', + 'fm.workspace.rightToLeft': 'Auswahl von rechts nach links übertragen', 'editor.edit': 'Bearbeiten', 'editor.save': 'Speichern', 'editor.cancel': 'Abbrechen', @@ -2069,6 +2195,48 @@ const translations = { 'fm.title': 'Gestionnaire de fichiers', 'fm.refresh': 'Actualiser', + 'fm.preview': 'Aperçu', + 'fm.name': 'Nom', + 'fm.size': 'Taille', + 'fm.modified': 'Modifié', + 'fm.permissions': 'Permissions', + 'fm.workspace.layout': 'Disposition du gestionnaire de fichiers', + 'fm.workspace.onePane': '1 volet', + 'fm.workspace.twoPanes': '2 volets', + 'fm.workspace.openSource': 'Ouvrir une source', + 'fm.workspace.openSourceIn': 'Ouvrir une source dans', + 'fm.workspace.selectAll': 'Sélectionner tous les fichiers', + 'fm.workspace.transferBetween': 'Transférer entre les volets', + 'fm.workspace.selectFiles': 'Sélectionner des fichiers', + 'fm.workspace.sourceHint': 'Choisissez une session SSH active, un hôte enregistré ou des fichiers locaux du navigateur.', + 'fm.workspace.searchSources': 'Rechercher des sources', + 'fm.workspace.newSftp': 'Nouvelle connexion SFTP', + 'fm.workspace.sftpOverSsh': 'SFTP via SSH', + 'fm.workspace.newSmb': 'Nouveau partage SMB', + 'fm.workspace.connected': 'Connecté', + 'fm.workspace.hostKeyTrusted': 'Clé hôte SSH approuvée', + 'fm.workspace.available': 'Disponible', + 'fm.workspace.authenticationRequired': 'Authentification requise', + 'fm.workspace.localFiles': 'Fichiers locaux', + 'fm.workspace.thisBrowser': 'Ce navigateur', + 'fm.workspace.unavailable': 'Indisponible', + 'fm.workspace.browserPermission': 'Autorisation du navigateur requise', + 'fm.workspace.smbShare': 'Partage SMB', + 'fm.workspace.comingSoon': 'Bientôt disponible', + 'fm.workspace.encryptionRequired': 'Chiffrement requis', + 'fm.workspace.activeSessions': 'Sessions SSH actives', + 'fm.workspace.savedHosts': 'Hôtes SSH enregistrés', + 'fm.workspace.quickConnections': 'Connexions rapides SFTP', + 'fm.workspace.browser': 'Ce navigateur', + 'fm.workspace.smbShares': 'Partages SMB', + 'fm.workspace.leftPane': 'Volet gauche', + 'fm.workspace.rightPane': 'Volet droit', + 'fm.workspace.noSources': 'Aucune source correspondante', + 'fm.workspace.untitledSource': 'Source', + 'fm.workspace.closeSource': 'Fermer la source', + 'fm.workspace.chooseSource': 'Choisir une source', + 'fm.workspace.leftToRight': 'Transférer la sélection de gauche à droite', + 'fm.workspace.rightToLeft': 'Transférer la sélection de droite à gauche', 'editor.edit': 'Modifier', 'editor.save': 'Enregistrer', 'editor.cancel': 'Annuler', @@ -2692,6 +2860,48 @@ const translations = { 'fm.title': 'Gestor de archivos', 'fm.refresh': 'Actualizar', + 'fm.preview': 'Vista previa', + 'fm.name': 'Nombre', + 'fm.size': 'Tamaño', + 'fm.modified': 'Modificado', + 'fm.permissions': 'Permisos', + 'fm.workspace.layout': 'Diseño del gestor de archivos', + 'fm.workspace.onePane': '1 panel', + 'fm.workspace.twoPanes': '2 paneles', + 'fm.workspace.openSource': 'Abrir origen', + 'fm.workspace.openSourceIn': 'Abrir origen en', + 'fm.workspace.selectAll': 'Seleccionar todos los archivos', + 'fm.workspace.transferBetween': 'Transferir entre paneles', + 'fm.workspace.selectFiles': 'Seleccionar archivos', + 'fm.workspace.sourceHint': 'Elige una sesión SSH activa, un host guardado o archivos locales del navegador.', + 'fm.workspace.searchSources': 'Buscar orígenes', + 'fm.workspace.newSftp': 'Nueva conexión SFTP', + 'fm.workspace.sftpOverSsh': 'SFTP sobre SSH', + 'fm.workspace.newSmb': 'Nuevo recurso SMB', + 'fm.workspace.connected': 'Conectado', + 'fm.workspace.hostKeyTrusted': 'Clave de host SSH confiable', + 'fm.workspace.available': 'Disponible', + 'fm.workspace.authenticationRequired': 'Autenticación requerida', + 'fm.workspace.localFiles': 'Archivos locales', + 'fm.workspace.thisBrowser': 'Este navegador', + 'fm.workspace.unavailable': 'No disponible', + 'fm.workspace.browserPermission': 'Se requiere permiso del navegador', + 'fm.workspace.smbShare': 'Recurso SMB', + 'fm.workspace.comingSoon': 'Próximamente', + 'fm.workspace.encryptionRequired': 'Cifrado requerido', + 'fm.workspace.activeSessions': 'Sesiones SSH activas', + 'fm.workspace.savedHosts': 'Hosts SSH guardados', + 'fm.workspace.quickConnections': 'Conexiones rápidas SFTP', + 'fm.workspace.browser': 'Este navegador', + 'fm.workspace.smbShares': 'Recursos SMB', + 'fm.workspace.leftPane': 'Panel izquierdo', + 'fm.workspace.rightPane': 'Panel derecho', + 'fm.workspace.noSources': 'No hay orígenes coincidentes', + 'fm.workspace.untitledSource': 'Origen', + 'fm.workspace.closeSource': 'Cerrar origen', + 'fm.workspace.chooseSource': 'Elegir origen', + 'fm.workspace.leftToRight': 'Transferir selección de izquierda a derecha', + 'fm.workspace.rightToLeft': 'Transferir selección de derecha a izquierda', 'editor.edit': 'Editar', 'editor.save': 'Guardar', 'editor.cancel': 'Cancelar', @@ -3315,6 +3525,48 @@ const translations = { 'fm.title': '文件管理器', 'fm.refresh': '刷新', + 'fm.preview': '预览', + 'fm.name': '名称', + 'fm.size': '大小', + 'fm.modified': '修改时间', + 'fm.permissions': '权限', + 'fm.workspace.layout': '文件管理器布局', + 'fm.workspace.onePane': '单面板', + 'fm.workspace.twoPanes': '双面板', + 'fm.workspace.openSource': '打开来源', + 'fm.workspace.openSourceIn': '打开来源于', + 'fm.workspace.selectAll': '选择所有文件', + 'fm.workspace.transferBetween': '在面板之间传输', + 'fm.workspace.selectFiles': '选择文件', + 'fm.workspace.sourceHint': '选择活动 SSH 会话、已保存的主机或浏览器本地文件。', + 'fm.workspace.searchSources': '搜索来源', + 'fm.workspace.newSftp': '新建 SFTP 连接', + 'fm.workspace.sftpOverSsh': '基于 SSH 的 SFTP', + 'fm.workspace.newSmb': '新建 SMB 共享', + 'fm.workspace.connected': '已连接', + 'fm.workspace.hostKeyTrusted': 'SSH 主机密钥已信任', + 'fm.workspace.available': '可用', + 'fm.workspace.authenticationRequired': '需要身份验证', + 'fm.workspace.localFiles': '本地文件', + 'fm.workspace.thisBrowser': '此浏览器', + 'fm.workspace.unavailable': '不可用', + 'fm.workspace.browserPermission': '需要浏览器权限', + 'fm.workspace.smbShare': 'SMB 共享', + 'fm.workspace.comingSoon': '即将推出', + 'fm.workspace.encryptionRequired': '需要加密', + 'fm.workspace.activeSessions': '活动 SSH 会话', + 'fm.workspace.savedHosts': '已保存的 SSH 主机', + 'fm.workspace.quickConnections': 'SFTP 快速连接', + 'fm.workspace.browser': '此浏览器', + 'fm.workspace.smbShares': 'SMB 共享', + 'fm.workspace.leftPane': '左面板', + 'fm.workspace.rightPane': '右面板', + 'fm.workspace.noSources': '没有匹配的来源', + 'fm.workspace.untitledSource': '来源', + 'fm.workspace.closeSource': '关闭来源', + 'fm.workspace.chooseSource': '选择来源', + 'fm.workspace.leftToRight': '将所选项从左侧传到右侧', + 'fm.workspace.rightToLeft': '将所选项从右侧传到左侧', 'editor.edit': '编辑', 'editor.save': '保存', 'editor.cancel': '取消', diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index b70a00d..66761fa 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -7,13 +7,9 @@ class SFTPFileManager { this.displayMode = 'closed'; this.embeddedContainer = null; - this.browserFS = new BrowserFileSystem(); + this.browserFS = this.createBrowserFileSystem(); - this.panes = { - left: this.createEmptyPaneState(), - right: this.createEmptyPaneState() - }; - this.activePane = 'left'; + this.initializeWorkspaceState(); this.availableSessions = []; this.quickConnections = []; @@ -55,8 +51,391 @@ class SFTPFileManager { pendingHomeRequestId: null, pendingDirectoryRequestId: null, pendingDirectoryPath: null, - autoHomeEligible: false + autoHomeEligible: false, + browserFS: null + }; + } + + createBrowserFileSystem() { + return new BrowserFileSystem(); + } + + getBrowserFileSystem(state) { + if (!state.browserFS) { + state.browserFS = typeof BrowserFileSystem === 'undefined' && this.browserFS + ? this.browserFS + : this.createBrowserFileSystem(); + } + return state.browserFS; + } + + initializeWorkspaceState() { + const WorkspaceState = window.FileWorkspaceState; + if (typeof WorkspaceState !== 'function') { + throw new Error('FileWorkspaceState must be loaded before SFTPFileManager'); + } + this.workspace = new WorkspaceState(() => this.createEmptyPaneState()); + this.workspaceEmptyPanes = { + left: this.createEmptyPaneState(), + right: this.createEmptyPaneState(), + }; + this.panes = this.workspaceEmptyPanes; + this.standalonePanes = this.panes; + this.embeddedPanes = null; + this.activePane = 'left'; + } + + syncPaneFromWorkspace(pane) { + const tab = this.workspace.getActiveTab(pane); + this.panes[pane] = tab?.paneState || this.workspaceEmptyPanes[pane]; + this.standalonePanes = this.panes; + return this.panes[pane]; + } + + enterEmbeddedPaneState() { + if (this.embeddedPanes) return this.embeddedPanes; + this.standalonePanes = this.panes; + this.embeddedPanes = { + left: this.createEmptyPaneState(), + right: this.createEmptyPaneState(), + }; + this.panes = this.embeddedPanes; + return this.embeddedPanes; + } + + restoreStandalonePaneState() { + if (!this.embeddedPanes) return this.panes; + this.panes = this.standalonePanes; + this.embeddedPanes = null; + ['left', 'right'].forEach(pane => this.syncPaneFromWorkspace(pane)); + return this.panes; + } + + setWorkspaceLayout(layout) { + this.workspace.setLayout(layout); + if (layout === 'split' && !this.workspace.getActiveTab('right') && this.displayMode === 'modal') { + this.openSourceLauncher('right'); + } + this.renderWorkspaceChrome(); + return layout; + } + + buildSourceCatalog() { + const active = (this.availableSessions || []) + .filter(session => session.connected !== false) + .map(session => ({ + key: `ssh:${session.id}`, + type: 'ssh', + label: session.displayName || session.name || `${session.username}@${session.host}`, + endpoint: `${session.host}:${session.port || 22}`, + protocol: 'SFTP', + status: this.t('fm.workspace.connected', 'Connected'), + security: this.t('fm.workspace.hostKeyTrusted', 'SSH host key trusted'), + sessionId: session.id, + })); + const saved = (this.qcProfiles || []).map(profile => ({ + key: `profile:${profile.id}`, + type: 'profile', + label: profile.name || `${profile.username}@${profile.host}`, + endpoint: `${profile.username}@${profile.host}:${profile.port || 22}`, + protocol: 'SFTP', + status: this.t('fm.workspace.available', 'Available'), + security: this.t('fm.workspace.authenticationRequired', 'Authentication required'), + profileId: profile.id, + })); + const quick = (this.quickConnections || []).map(connection => ({ + key: `qc:${connection.connectionId}`, + type: 'ssh', + label: `${connection.username}@${connection.host}`, + endpoint: `${connection.host}:${connection.port || 22}`, + protocol: 'SFTP', + status: this.t('fm.workspace.connected', 'Connected'), + security: this.t('fm.workspace.hostKeyTrusted', 'SSH host key trusted'), + connectionId: connection.connectionId, + })); + const browser = [{ + key: 'browser-local', + type: 'browser-local', + label: this.t('fm.workspace.localFiles', 'Local files'), + endpoint: this.t('fm.workspace.thisBrowser', 'This browser'), + protocol: 'LOCAL', + status: this.browserFS?.isSupported + ? this.t('fm.workspace.available', 'Available') + : this.t('fm.workspace.unavailable', 'Unavailable'), + security: this.t('fm.workspace.browserPermission', 'Browser permission required'), + disabled: !this.browserFS?.isSupported, + }]; + const smb = [{ + key: 'smb:coming-soon', + type: 'smb', + label: this.t('fm.workspace.smbShare', 'SMB share'), + endpoint: 'SMB 3', + protocol: 'SMB', + status: this.t('fm.workspace.comingSoon', 'Coming soon'), + security: this.t('fm.workspace.encryptionRequired', 'Encryption required'), + disabled: true, + }]; + return [ + { id: 'active', label: this.t('fm.workspace.activeSessions', 'Active SSH sessions'), items: active }, + { id: 'saved', label: this.t('fm.workspace.savedHosts', 'Saved SSH hosts'), items: saved }, + { id: 'quick', label: this.t('fm.workspace.quickConnections', 'SFTP quick connections'), items: quick }, + { id: 'smb', label: this.t('fm.workspace.smbShares', 'SMB shares'), items: smb }, + { id: 'browser', label: this.t('fm.workspace.browser', 'This browser'), items: browser }, + ]; + } + + async openWorkspaceSource(pane, source) { + if (!source || source.disabled || source.type === 'smb') return null; + if (source.type === 'profile') { + this.pendingQuickConnectPane = pane; + this.openQuickConnect(source.profileId); + return null; + } + + const tab = this.workspace.openTab(pane, source, this.createEmptyPaneState()); + this.syncPaneFromWorkspace(pane); + this.workspace.setActivePane(pane); + this.setActivePane(pane); + this.closeSourceLauncher(); + this.renderWorkspaceChrome(); + await this.onSourceChange(pane, source.key); + this.renderWorkspaceChrome(); + return tab; + } + + loadWorkspaceProfiles() { + if (!this.socket || this.workspaceProfilesPending) return; + this.workspaceProfilesPending = true; + this.socket.emit('list_profiles'); + this.socket.once('profiles_list', data => { + this.workspaceProfilesPending = false; + this.qcProfiles = data?.profiles || []; + if (this.sourceLauncherPane) this.renderSourceLauncher(); + }); + } + + openSourceLauncher(pane = this.workspace.activePane) { + if (this.displayMode === 'embedded') return false; + this.workspace.setActivePane(pane); + this.sourceLauncherPane = pane; + this.sourceLauncherReturnFocus = document.activeElement; + this.loadWorkspaceProfiles(); + this.renderSourceLauncher(''); + + const launcher = document.getElementById('fmSourceLauncher'); + launcher.classList.add('show'); + launcher.setAttribute('aria-hidden', 'false'); + const paneLabel = document.getElementById('fmSourceLauncherPane'); + paneLabel.textContent = pane === 'left' + ? this.t('fm.workspace.leftPane', 'Left pane') + : this.t('fm.workspace.rightPane', 'Right pane'); + const search = document.getElementById('fmSourceSearch'); + search.value = ''; + search.focus(); + return true; + } + + closeSourceLauncher() { + const launcher = document.getElementById('fmSourceLauncher'); + if (!launcher) return; + launcher.classList.remove('show'); + launcher.setAttribute('aria-hidden', 'true'); + this.sourceLauncherPane = null; + if (this.sourceLauncherReturnFocus?.isConnected) this.sourceLauncherReturnFocus.focus(); + this.sourceLauncherReturnFocus = null; + } + + renderSourceLauncher(query = document.getElementById('fmSourceSearch')?.value || '') { + const container = document.getElementById('fmSourceGroups'); + if (!container) return; + const normalizedQuery = query.trim().toLocaleLowerCase(); + const groups = this.buildSourceCatalog(); + this.sourceCatalogByKey = new Map(); + const html = groups.map(group => { + const items = group.items.filter(source => { + this.sourceCatalogByKey.set(source.key, source); + if (!normalizedQuery) return true; + return [source.label, source.endpoint, source.protocol, source.status, source.security] + .some(value => String(value || '').toLocaleLowerCase().includes(normalizedQuery)); + }); + if (items.length === 0) return ''; + const rows = items.map(source => { + const icon = source.type === 'browser-local' ? 'computer' + : source.type === 'smb' ? 'dns' + : source.type === 'profile' ? 'bookmark' + : 'terminal'; + const disabled = source.disabled ? ' disabled aria-disabled="true"' : ''; + const comingSoon = source.type === 'smb' + ? `${this.escapeHtml(this.t('fm.workspace.comingSoon', 'Coming soon'))}` + : ''; + return ` + `; + }).join(''); + return `
+

${this.escapeHtml(group.label)}

+
${rows}
+
`; + }).join(''); + container.innerHTML = html || `
${this.escapeHtml(this.t('fm.workspace.noSources', 'No matching sources'))}
`; + } + + renderWorkspaceChrome() { + if (!this.modal || this.displayMode === 'embedded') return; + const single = this.workspace.layout === 'single'; + this.modal.classList.toggle('fm-workspace-single', single); + this.modal.classList.toggle('fm-workspace-split', !single); + this.modal.classList.toggle('fm-single-right', single && this.workspace.activePane === 'right'); + const singleButton = document.getElementById('fmLayoutSingle'); + const splitButton = document.getElementById('fmLayoutSplit'); + singleButton?.classList.toggle('active', single); + splitButton?.classList.toggle('active', !single); + singleButton?.setAttribute('aria-pressed', String(single)); + splitButton?.setAttribute('aria-pressed', String(!single)); + ['left', 'right'].forEach(pane => { + this.renderSourceTabs(pane); + this.renderSourceIdentity(pane); + const activeTab = this.workspace.getActiveTab(pane); + const legacySelect = document.getElementById(`fm${this.capitalize(pane)}Source`); + if (legacySelect) legacySelect.value = activeTab?.source.key || ''; + }); + this.updateWorkspaceActions(); + } + + renderSourceTabs(pane) { + const container = document.getElementById(`fm${this.capitalize(pane)}Tabs`); + if (!container) return; + const activeTab = this.workspace.getActiveTab(pane); + container.innerHTML = this.workspace.getTabs(pane).map(tab => ` + `).join(''); + } + + renderSourceIdentity(pane) { + const container = document.getElementById(`fm${this.capitalize(pane)}Identity`); + if (!container) return; + const tab = this.workspace.getActiveTab(pane); + if (!tab) { + container.innerHTML = ``; + container.querySelector('button')?.addEventListener('click', () => this.openSourceLauncher(pane)); + return; + } + container.innerHTML = ` + ${this.escapeHtml(tab.source.label || '')} + ${this.escapeHtml(tab.source.protocol || '')} + ${this.escapeHtml(tab.source.endpoint || '')} + ${this.escapeHtml(tab.source.security || '')}`; + } + + activateSourceTab(pane, tabId) { + const tab = this.workspace.activateTab(pane, tabId); + if (!tab) return null; + this.syncPaneFromWorkspace(pane); + this.setActivePane(pane); + this.updatePathInput(pane, tab.paneState.path || '/'); + this.updatePaneBadge(pane); + this.renderPane(pane); + this.renderWorkspaceChrome(); + return tab; + } + + closeSourceTab(pane, tabId) { + const result = this.workspace.closeTab(pane, tabId); + if (!result.closed) return null; + this.syncPaneFromWorkspace(pane); + this.updatePathInput(pane, this.panes[pane].path || '/'); + this.updatePaneBadge(pane); + this.renderPane(pane); + this.renderWorkspaceChrome(); + if (!result.active && this.workspace.layout === 'single') this.openSourceLauncher(pane); + return result.closed; + } + + previewSelected() { + const state = this.panes[this.activePane]; + if (!state || state.selected.size !== 1) return false; + const index = Array.from(state.selected)[0]; + const file = state.files[index]; + if (!file || file.is_dir) return false; + this.handleItemDblClick(this.activePane, index); + return true; + } + + updateWorkspaceActions() { + if (this.displayMode !== 'modal' || !this.workspace) return; + const state = this.panes[this.activePane]; + const selectedCount = state?.selected?.size || 0; + const selectedFile = selectedCount === 1 ? state.files[Array.from(state.selected)[0]] : null; + const hasSource = Boolean(state?.type); + const bothSources = Boolean(this.workspace.getActiveTab('left') && this.workspace.getActiveTab('right')); + const transferAvailable = this.workspace.layout === 'split' && bothSources && selectedCount > 0; + const setDisabled = (id, disabled) => { + const element = document.getElementById(id); + if (element) element.disabled = disabled; }; + setDisabled('fmNewFolder', !hasSource); + setDisabled('fmEmbeddedUpload', state?.type !== 'ssh'); + setDisabled('fmDownload', !hasSource || selectedCount === 0); + setDisabled('fmPreview', !selectedFile || selectedFile.is_dir); + setDisabled('fmRename', selectedCount !== 1); + setDisabled('fmDelete', selectedCount === 0); + setDisabled('fmTransfer', !transferAvailable); + setDisabled('fmTransferRight', !(this.workspace.layout === 'split' && bothSources && this.panes.left.selected.size > 0)); + setDisabled('fmTransferLeft', !(this.workspace.layout === 'split' && bothSources && this.panes.right.selected.size > 0)); + ['left', 'right'].forEach(pane => { + const paneState = this.panes[pane]; + const paneSelection = paneState?.selected?.size || 0; + const onlyFile = paneSelection === 1 + ? paneState.files[Array.from(paneState.selected)[0]] + : null; + document.querySelectorAll(`[data-pane-toolbar="${pane}"] [data-pane-action]`).forEach(button => { + const action = button.dataset.paneAction; + const disabled = { + newfolder: !paneState?.type, + upload: paneState?.type !== 'ssh', + download: !paneState?.type || paneSelection === 0, + preview: !onlyFile || onlyFile.is_dir, + rename: paneSelection !== 1, + delete: paneSelection === 0, + }[action]; + button.disabled = Boolean(disabled); + }); + const selectAll = document.querySelector(`[data-pane-select-all="${pane}"]`); + if (selectAll) { + selectAll.disabled = !paneState?.type || paneState.files.length === 0; + selectAll.checked = paneState.files.length > 0 && paneSelection === paneState.files.length; + selectAll.indeterminate = paneSelection > 0 && paneSelection < paneState.files.length; + } + }); + const hint = document.getElementById('fmTransferHint'); + if (hint) { + hint.textContent = transferAvailable + ? (this.activePane === 'left' + ? this.t('fm.workspace.leftToRight', 'Move selected left to right') + : this.t('fm.workspace.rightToLeft', 'Move selected right to left')) + : this.t('fm.workspace.selectFiles', 'Select files'); + } } init() { @@ -75,9 +454,32 @@ class SFTPFileManager { modal.setAttribute('aria-hidden', 'true'); modal.innerHTML = `