diff --git a/Makefile b/Makefile index e9f416a..5f319f1 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ dev: deps # Production build build: deps - $(WAILS) build + $(WAILS) build -ldflags "-s -w" # Run tests test: diff --git a/README.md b/README.md index 2cb110e..7dba755 100644 --- a/README.md +++ b/README.md @@ -31,20 +31,20 @@ Prefer a one-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** to quick connec All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux. -| Shortcut | Action | -| ----------------- | --------------------------------- | -| Cmd + K | Open command palette | -| Cmd + N | New connection | -| Cmd + W | Close current tab | -| Cmd + Tab | Next tab | -| Cmd + Shift + Tab | Previous tab | -| Cmd + 1–9 | Switch to tab by number | -| Cmd + , | Open settings | -| Cmd + F | Find in terminal | -| Cmd + Shift + D | Split pane vertically | -| Cmd + Shift + E | Split pane horizontally | -| Cmd + Shift + R | Toggle session recording | -| Cmd + Shift + B | Toggle broadcast input mode | -| Cmd + Shift + S | Open snippet library | -| Cmd + Shift + Q | Quick connect | -| F11 | Toggle fullscreen (Windows/Linux) | +| macOS Shortcut | Windows/Linux Shortcut | Action | +| ------------- | ----------------------- | --------------------------------- | +| Cmd + K | Ctrl + K | Open command palette | +| Cmd + N | Ctrl + N | New connection | +| Cmd + W | Ctrl + W | Close current tab | +| Cmd + Tab | Ctrl + Tab | Next tab | +| Cmd + Shift + Tab | Ctrl + Shift + Tab | Previous tab | +| Cmd + 1–9 | Ctrl + 1–9 | Switch to tab by number | +| Cmd + , | Ctrl + , | Open settings | +| Cmd + F | Ctrl + F | Find in terminal | +| Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically | +| Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally | +| Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording | +| Cmd + Shift + B | Ctrl + Shift + B | Toggle broadcast input mode | +| Cmd + Shift + S | Ctrl + Shift + S | Open snippet library | +| Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect | +| F11 | F11 | Toggle fullscreen | diff --git a/build-all.sh b/build-all.sh index 3ac9f57..5a639b9 100755 --- a/build-all.sh +++ b/build-all.sh @@ -94,9 +94,9 @@ build_target() { log "Building ${platform}…" if [ -n "$cc" ]; then - CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" + CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "-s -w" else - CGO_ENABLED=1 wails build -platform "$platform" + CGO_ENABLED=1 wails build -platform "$platform" -ldflags "-s -w" fi } diff --git a/build.ps1 b/build.ps1 index 9e72d22..509493e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -61,7 +61,7 @@ function Build { $env:CGO_ENABLED = '1' Write-Info 'Building production binary (CGO_ENABLED=1)...' - & $wailsPath build + & $wailsPath build -ldflags "-s -w" Write-Info 'Build complete: build\bin\' } diff --git a/build.sh b/build.sh index 6ec2112..a8ab195 100755 --- a/build.sh +++ b/build.sh @@ -62,7 +62,7 @@ build() { check_deps install_deps log "Building production binary…" - wails build + wails build -ldflags "-s -w" log "Build complete: build/bin/" } diff --git a/docs/quick-start.md b/docs/quick-start.md index 2773605..55502e6 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -14,9 +14,9 @@ One-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** for quick connect. ## Daily workflow - **Tabs and splits**: Drag tabs to reorder, and split the workspace to view multiple sessions at once. -- **Broadcast input**: Toggle with **Cmd+Shift+B** to send the same command to all connected sessions. +- **Broadcast input**: Toggle with **Cmd+Shift+B** (macOS) or **Ctrl+Shift+B** (Windows/Linux) to send the same command to all connected sessions. - **Snippets**: Save common commands and run them with one click. -- **Search**: Press **Cmd+F** to search within the active terminal. +- **Search**: Press **Cmd+F** (macOS) or **Ctrl+F** (Windows/Linux) to search within the active terminal. ## File transfers on SSH @@ -42,18 +42,18 @@ The upload picker lists folders only and marks permissions: ## Common shortcuts -| Shortcut | Action | -| ----------------- | --------------------------- | -| Cmd + K | Command palette | -| Cmd + N | New connection | -| Cmd + W | Close tab | -| Cmd + F | Find in terminal | -| Cmd + Shift + S | Snippet library | -| Cmd + Shift + B | Broadcast input | -| Cmd + Shift + Q | Quick connect | -| Cmd + Shift + R | Toggle session recording | -| Cmd + Shift + D | Split pane vertically | -| Cmd + Shift + E | Split pane horizontally | +| macOS Shortcut | Windows/Linux Shortcut | Action | +| ------------- | ----------------------- | ------------------------ | +| Cmd + K | Ctrl + K | Command palette | +| Cmd + N | Ctrl + N | New connection | +| Cmd + W | Ctrl + W | Close tab | +| Cmd + F | Ctrl + F | Find in terminal | +| Cmd + Shift + S | Ctrl + Shift + S | Snippet library | +| Cmd + Shift + B | Ctrl + Shift + B | Broadcast input | +| Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect | +| Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording | +| Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically | +| Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally | ## Need more detail? diff --git a/docs/user-manual.md b/docs/user-manual.md index f9ffc08..43a2cb1 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -122,7 +122,7 @@ Screenshot: Terminal context menu and search bar. ### Search -Press **Cmd+F** to search in the active terminal. Toggle case sensitivity and regular expressions as needed. +Press **Cmd+F** (macOS) or **Ctrl+F** (Windows/Linux) to search in the active terminal. Toggle case sensitivity and regular expressions as needed. --- @@ -130,13 +130,13 @@ Press **Cmd+F** to search in the active terminal. Toggle case sensitivity and re ### Snippets -Open the snippet library with **Cmd+Shift+S** and save frequently-used commands. Send a snippet to the active session with one click. +Open the snippet library with **Cmd+Shift+S** (macOS) or **Ctrl+Shift+S** (Windows/Linux) and save frequently-used commands. Send a snippet to the active session with one click. Screenshot: Snippet library with search and folder grouping. ### Broadcast input -Toggle broadcast mode with **Cmd+Shift+B**. While active, all input is sent to every connected session. +Toggle broadcast mode with **Cmd+Shift+B** (macOS) or **Ctrl+Shift+B** (Windows/Linux). While active, all input is sent to every connected session. --- @@ -184,7 +184,7 @@ Tip: Use the filter box to jump quickly to common folders. ## 10. Session recordings -Toggle recording with **Cmd+Shift+R**. Recordings are saved as asciicast v2 files and can be replayed from the recordings panel. +Toggle recording with **Cmd+Shift+R** (macOS) or **Ctrl+Shift+R** (Windows/Linux). Recordings are saved as asciicast v2 files and can be replayed from the recordings panel. Screenshot: Recordings list with playback controls. @@ -216,7 +216,7 @@ Screenshot: TOTP configuration in the profile editor. ## 12. Settings -Open settings with **Cmd+,** and configure: +Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux) and configure: - Terminal themes and fonts - Default behaviours diff --git a/frontend/src/css/forms.css b/frontend/src/css/forms.css index d01e720..1aa68a3 100644 --- a/frontend/src/css/forms.css +++ b/frontend/src/css/forms.css @@ -459,6 +459,13 @@ color: var(--danger); } +.form-success { + font-size: var(--text-xs); + color: var(--success); + margin-top: var(--space-1); + line-height: var(--leading-relaxed); +} + /* ----------------------------------------------------------------------- Toggle wrapper — label + toggle switch in a row ----------------------------------------------------------------------- */ @@ -684,6 +691,21 @@ flex: 1; } +/* SSH key path row */ +.key-path-row { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.key-path-row .form-input { + flex: 1; +} + +.jump-hosts-list .form-error { + margin-bottom: var(--space-2); +} + .totp-toggle, .totp-test, .totp-save, diff --git a/frontend/src/css/modals.css b/frontend/src/css/modals.css index 6483cbf..0a8e81e 100644 --- a/frontend/src/css/modals.css +++ b/frontend/src/css/modals.css @@ -8,140 +8,148 @@ Modal backdrop ----------------------------------------------------------------------- */ .modal-backdrop { - position: fixed; - inset: 0; - z-index: var(--z-modal-backdrop); - background: rgba(10, 10, 20, 0.60); - backdrop-filter: blur(2px); - opacity: 0; - visibility: hidden; - transition: - opacity var(--transition-normal), - visibility var(--transition-normal); + position: fixed; + inset: 0; + z-index: var(--z-modal-backdrop); + background: rgba(10, 10, 20, 0.6); + backdrop-filter: blur(2px); + opacity: 0; + visibility: hidden; + transition: + opacity var(--transition-normal), + visibility var(--transition-normal); } .modal-backdrop.is-visible { - opacity: 1; - visibility: visible; + opacity: 1; + visibility: visible; } /* ----------------------------------------------------------------------- Modal (centred panel) ----------------------------------------------------------------------- */ .modal { - position: fixed; - z-index: var(--z-modal); - top: 50%; - left: 50%; - transform: translate(-50%, -50%) scale(0.96); - width: 480px; - max-width: calc(100vw - var(--space-8)); - max-height: calc(100vh - var(--space-8)); - display: flex; - flex-direction: column; - background: var(--bg-elevated); - border: 1px solid var(--border-default); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-xl); - opacity: 0; - visibility: hidden; - transition: - opacity var(--transition-normal), - visibility var(--transition-normal), - transform var(--transition-normal); + position: fixed; + z-index: var(--z-modal); + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.96); + width: 480px; + max-width: calc(100vw - var(--space-8)); + max-height: calc(100vh - var(--space-8)); + display: flex; + flex-direction: column; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + opacity: 0; + visibility: hidden; + transition: + opacity var(--transition-normal), + visibility var(--transition-normal), + transform var(--transition-normal); } .modal.is-visible { - opacity: 1; - visibility: visible; - transform: translate(-50%, -50%) scale(1); + opacity: 1; + visibility: visible; + transform: translate(-50%, -50%) scale(1); } /* Size variants */ -.modal-sm { width: 360px; } -.modal-lg { width: 640px; } -.modal-xl { width: 800px; } +.modal-sm { + width: 360px; +} +.modal-lg { + width: 640px; +} +.modal-xl { + width: 800px; +} .modal-header { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-4) var(--space-5); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; } .modal-header-icon { - width: 20px; - height: 20px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: var(--text-lg); - color: var(--text-tertiary); + width: 20px; + height: 20px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-lg); + color: var(--text-tertiary); } .modal-title { - flex: 1; - font-size: var(--text-md); - font-weight: var(--weight-semibold); - color: var(--text-primary); + flex: 1; + font-size: var(--text-md); + font-weight: var(--weight-semibold); + color: var(--text-primary); } .modal-close { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-md); - font-size: var(--text-md); - color: var(--text-tertiary); - cursor: pointer; - transition: background var(--transition-fast), color var(--transition-fast); - flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + font-size: var(--text-md); + color: var(--text-tertiary); + cursor: pointer; + transition: + background var(--transition-fast), + color var(--transition-fast); + flex-shrink: 0; } .modal-close:hover { - background: var(--bg-hover); - color: var(--text-primary); + background: var(--bg-hover); + color: var(--text-primary); } .modal-body { - flex: 1; - overflow-y: auto; - padding: var(--space-5); + flex: 1; + overflow-y: auto; + padding: var(--space-5); } .modal-body.sftp-picker-body { - display: flex; - flex-direction: column; - padding: 0; - min-height: 320px; + display: flex; + flex-direction: column; + padding: 0; + min-height: 320px; } .modal-body p { - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: var(--leading-relaxed); + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-relaxed); } .modal-body p + p { - margin-top: var(--space-3); + margin-top: var(--space-3); } .modal-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-2); - padding: var(--space-3) var(--space-5); - border-top: 1px solid var(--border-subtle); - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; } .modal-footer-left { - margin-right: auto; + margin-right: auto; } /* ----------------------------------------------------------------------- @@ -149,703 +157,732 @@ Used for the profile editor. ----------------------------------------------------------------------- */ .drawer-backdrop { - position: fixed; - inset: 0; - z-index: var(--z-modal-backdrop); - background: rgba(10, 10, 20, 0.50); - opacity: 0; - visibility: hidden; - transition: - opacity var(--transition-normal), - visibility var(--transition-normal); + position: fixed; + inset: 0; + z-index: var(--z-modal-backdrop); + background: rgba(10, 10, 20, 0.5); + opacity: 0; + visibility: hidden; + transition: + opacity var(--transition-normal), + visibility var(--transition-normal); } .drawer-backdrop.is-visible { - opacity: 1; - visibility: visible; + opacity: 1; + visibility: visible; } .drawer { - position: fixed; - z-index: var(--z-modal); - top: 0; - right: 0; - width: 480px; - max-width: 100vw; - height: 100vh; - display: flex; - flex-direction: column; - background: var(--bg-elevated); - border-left: 1px solid var(--border-default); - box-shadow: var(--shadow-xl); - transform: translateX(100%); - transition: transform var(--transition-slow); + position: fixed; + z-index: var(--z-modal); + top: 0; + right: 0; + width: 480px; + max-width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg-elevated); + border-left: 1px solid var(--border-default); + box-shadow: var(--shadow-xl); + transform: translateX(100%); + transition: transform var(--transition-slow); } .drawer.is-visible { - transform: translateX(0); + transform: translateX(0); } /* Drawer width variants */ -.drawer-wide { width: 560px; } -.drawer-narrow { width: 380px; } +.drawer-wide { + width: 560px; +} +.drawer-narrow { + width: 380px; +} .drawer-header { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-4) var(--space-5); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; } .drawer-title { - flex: 1; - font-size: var(--text-md); - font-weight: var(--weight-semibold); - color: var(--text-primary); + flex: 1; + font-size: var(--text-md); + font-weight: var(--weight-semibold); + color: var(--text-primary); } .drawer-close { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-md); - font-size: var(--text-md); - color: var(--text-tertiary); - cursor: pointer; - transition: background var(--transition-fast), color var(--transition-fast); - flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + font-size: var(--text-md); + color: var(--text-tertiary); + cursor: pointer; + transition: + background var(--transition-fast), + color var(--transition-fast); + flex-shrink: 0; } .drawer-close:hover { - background: var(--bg-hover); - color: var(--text-primary); + background: var(--bg-hover); + color: var(--text-primary); } .drawer-body { - flex: 1; - overflow-y: auto; - padding: var(--space-5); + flex: 1; + overflow-y: auto; + padding: var(--space-5); } .drawer-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-2); - padding: var(--space-3) var(--space-5); - border-top: 1px solid var(--border-subtle); - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; } /* Protocol selector tabs inside the drawer */ .drawer-protocol-tabs { - display: flex; - gap: 0; - padding: 0 var(--space-5); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; + display: flex; + gap: 0; + padding: 0 var(--space-5); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; } .drawer-proto-tab { - position: relative; - padding: var(--space-2) var(--space-4); - background: none; - border: none; - color: var(--text-secondary); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - cursor: pointer; - letter-spacing: 0.04em; - transition: color var(--transition-fast); + position: relative; + padding: var(--space-2) var(--space-4); + background: none; + border: none; + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + letter-spacing: 0.04em; + transition: color var(--transition-fast); } .drawer-proto-tab:hover { - color: var(--text-primary); + color: var(--text-primary); } .drawer-proto-tab.active { - color: var(--accent); + color: var(--accent); } .drawer-proto-tab.active::after { - content: ''; - position: absolute; - bottom: -1px; - left: var(--space-2); - right: var(--space-2); - height: 2px; - background: var(--accent); - border-radius: 1px; + content: ""; + position: absolute; + bottom: -1px; + left: var(--space-2); + right: var(--space-2); + height: 2px; + background: var(--accent); + border-radius: 1px; } /* Settings tab bar */ .settings-tab-bar { - display: flex; - gap: 0; - padding: 0 var(--space-5); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; + display: flex; + gap: 0; + padding: 0 var(--space-5); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; } .settings-tab { - position: relative; - padding: var(--space-2) var(--space-3); - background: none; - border: none; - color: var(--text-secondary); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - cursor: pointer; - transition: color var(--transition-fast); + position: relative; + padding: var(--space-2) var(--space-3); + background: none; + border: none; + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + transition: color var(--transition-fast); } .settings-tab:hover { - color: var(--text-primary); + color: var(--text-primary); } .settings-tab.active { - color: var(--accent); + color: var(--accent); } .settings-tab.active::after { - content: ''; - position: absolute; - bottom: -1px; - left: var(--space-1); - right: var(--space-1); - height: 2px; - background: var(--accent); - border-radius: 1px; + content: ""; + position: absolute; + bottom: -1px; + left: var(--space-1); + right: var(--space-1); + height: 2px; + background: var(--accent); + border-radius: 1px; } .settings-body { - padding: var(--space-5); + padding: var(--space-5); } /* Help tab — two-column layout with navigation sidebar */ .settings-body.help-layout { - display: flex; - gap: 0; - padding: 0; - overflow: hidden; + display: flex; + gap: 0; + padding: 0; + overflow: hidden; } .help-nav { - width: 180px; - min-width: 160px; - flex-shrink: 0; - display: flex; - flex-direction: column; - padding: var(--space-2) 0; - border-right: 1px solid var(--border-subtle); - overflow-y: auto; - background: var(--bg-surface); + width: 180px; + min-width: 160px; + flex-shrink: 0; + display: flex; + flex-direction: column; + padding: var(--space-2) 0; + border-right: 1px solid var(--border-subtle); + overflow-y: auto; + background: var(--bg-surface); } .help-nav-item { - display: block; - width: 100%; - padding: var(--space-2) var(--space-4); - font-size: var(--text-sm); - color: var(--text-secondary); - background: none; - border: none; - text-align: left; - cursor: pointer; - transition: background var(--transition-fast), color var(--transition-fast); - border-left: 2px solid transparent; + display: block; + width: 100%; + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + color: var(--text-secondary); + background: none; + border: none; + text-align: left; + cursor: pointer; + transition: + background var(--transition-fast), + color var(--transition-fast); + border-left: 2px solid transparent; } .help-nav-item:hover { - background: var(--bg-hover); - color: var(--text-primary); + background: var(--bg-hover); + color: var(--text-primary); } .help-nav-item.active { - color: var(--accent); - background: var(--bg-elevated); - border-left-color: var(--accent); - font-weight: var(--weight-medium); + color: var(--accent); + background: var(--bg-elevated); + border-left-color: var(--accent); + font-weight: var(--weight-medium); } .help-content { - flex: 1; - padding: var(--space-4) var(--space-5); - overflow-y: auto; - min-width: 0; - font-size: var(--text-sm); - line-height: 1.65; - color: var(--text-secondary); + flex: 1; + padding: var(--space-4) var(--space-5); + overflow-y: auto; + min-width: 0; + font-size: var(--text-sm); + line-height: 1.65; + color: var(--text-secondary); } .help-section-title { - font-size: var(--text-lg); - font-weight: var(--weight-semibold); - color: var(--text-primary); - margin: 0 0 var(--space-3) 0; - padding-bottom: var(--space-2); - border-bottom: 1px solid var(--border-subtle); + font-size: var(--text-lg); + font-weight: var(--weight-semibold); + color: var(--text-primary); + margin: 0 0 var(--space-3) 0; + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-subtle); } .help-content h4 { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--text-primary); - margin: var(--space-4) 0 var(--space-2) 0; + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--text-primary); + margin: var(--space-4) 0 var(--space-2) 0; } .help-content p { - margin: 0 0 var(--space-2) 0; + margin: 0 0 var(--space-2) 0; } -.help-content ul, .help-content ol { - margin: 0 0 var(--space-3) 0; - padding-left: var(--space-5); +.help-content ul, +.help-content ol { + margin: 0 0 var(--space-3) 0; + padding-left: var(--space-5); } .help-content li { - margin-bottom: var(--space-1); + margin-bottom: var(--space-1); } .help-content code { - font-family: 'Menlo', 'Consolas', 'Courier New', monospace; - font-size: 0.9em; - background: var(--bg-base); - padding: 1px 5px; - border-radius: var(--radius-sm); - color: var(--accent); + font-family: "Menlo", "Consolas", "Courier New", monospace; + font-size: 0.9em; + background: var(--bg-base); + padding: 1px 5px; + border-radius: var(--radius-sm); + color: var(--accent); } .help-content kbd { - display: inline-block; - font-family: 'Menlo', 'Consolas', 'Courier New', monospace; - font-size: 0.8em; - padding: 1px 5px; - background: var(--bg-base); - border: 1px solid var(--border-default); - border-radius: var(--radius-sm); - color: var(--text-primary); - box-shadow: 0 1px 0 var(--border-strong, var(--border-default)); - line-height: 1.4; + display: inline-block; + font-family: "Menlo", "Consolas", "Courier New", monospace; + font-size: 0.8em; + padding: 1px 5px; + background: var(--bg-base); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-primary); + box-shadow: 0 1px 0 var(--border-strong, var(--border-default)); + line-height: 1.4; } .help-content em { - color: var(--text-tertiary); - font-style: italic; + color: var(--text-tertiary); + font-style: italic; } .help-content strong { - color: var(--text-primary); - font-weight: var(--weight-medium); + color: var(--text-primary); + font-weight: var(--weight-medium); } .help-shortcuts-table { - width: 100%; - border-collapse: collapse; - margin: var(--space-2) 0 var(--space-3) 0; + width: 100%; + border-collapse: collapse; + margin: var(--space-2) 0 var(--space-3) 0; } .help-shortcuts-table td { - padding: var(--space-1) var(--space-2); - border-bottom: 1px solid var(--border-subtle); - font-size: var(--text-sm); + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--border-subtle); + font-size: var(--text-sm); } .help-shortcuts-table td:first-child { - width: 140px; - white-space: nowrap; - color: var(--text-primary); + width: 140px; + white-space: nowrap; + color: var(--text-primary); } /* Command palette */ .palette-overlay { - position: fixed; - inset: 0; - z-index: var(--z-modal-backdrop); - background: rgba(10, 10, 20, 0.50); - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: 20vh; + position: fixed; + inset: 0; + z-index: var(--z-modal-backdrop); + background: rgba(10, 10, 20, 0.5); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 20vh; } .palette { - width: 520px; - max-width: calc(100vw - var(--space-8)); - max-height: 400px; - display: flex; - flex-direction: column; - background: var(--bg-elevated); - border: 1px solid var(--border-default); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-xl); - overflow: hidden; + width: 520px; + max-width: calc(100vw - var(--space-8)); + max-height: 400px; + display: flex; + flex-direction: column; + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + overflow: hidden; } .palette-input { - width: 100%; - padding: var(--space-3) var(--space-4); - background: transparent; - border: none; - border-bottom: 1px solid var(--border-subtle); - color: var(--text-primary); - font-size: var(--text-base); - outline: none; + width: 100%; + padding: var(--space-3) var(--space-4); + background: transparent; + border: none; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + font-size: var(--text-base); + outline: none; } .palette-list { - flex: 1; - overflow-y: auto; - padding: var(--space-1) 0; + flex: 1; + overflow-y: auto; + padding: var(--space-1) 0; } .palette-item { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-4); - cursor: pointer; - transition: background var(--transition-fast); + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-4); + cursor: pointer; + transition: background var(--transition-fast); } .palette-item:hover, .palette-item-active { - background: var(--bg-hover); + background: var(--bg-hover); } .palette-item-label { - flex: 1; - font-size: var(--text-sm); - color: var(--text-primary); + flex: 1; + font-size: var(--text-sm); + color: var(--text-primary); } .palette-item-detail { - font-size: var(--text-xs); - color: var(--text-tertiary); + font-size: var(--text-xs); + color: var(--text-tertiary); } .palette-item-shortcut { - font-size: var(--text-xs); - color: var(--text-tertiary); - background: var(--bg-surface); - padding: var(--space-half) var(--space-1); - border-radius: var(--radius-sm); - font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-tertiary); + background: var(--bg-surface); + padding: var(--space-half) var(--space-1); + border-radius: var(--radius-sm); + font-family: var(--font-mono); } .palette-section { - padding: var(--space-1) var(--space-4); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.06em; + padding: var(--space-1) var(--space-4); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; } .palette-empty { - padding: var(--space-6) var(--space-4); - text-align: center; - font-size: var(--text-sm); - color: var(--text-tertiary); + padding: var(--space-6) var(--space-4); + text-align: center; + font-size: var(--text-sm); + color: var(--text-tertiary); } /* ----------------------------------------------------------------------- Confirmation dialog ----------------------------------------------------------------------- */ .confirm-dialog { - width: 400px; + width: 400px; } .confirm-dialog .modal-body { - text-align: center; - padding: var(--space-6) var(--space-5); + text-align: center; + padding: var(--space-6) var(--space-5); } .confirm-dialog-icon { - width: 48px; - height: 48px; - margin: 0 auto var(--space-4); - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - font-size: var(--text-2xl); + width: 48px; + height: 48px; + margin: 0 auto var(--space-4); + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: var(--text-2xl); } .confirm-dialog-icon.is-warning { - background: rgba(255, 167, 38, 0.12); - color: var(--warning); + background: rgba(255, 167, 38, 0.12); + color: var(--warning); } .confirm-dialog-icon.is-danger { - background: rgba(239, 83, 80, 0.12); - color: var(--error); + background: rgba(239, 83, 80, 0.12); + color: var(--error); } .confirm-dialog-icon.is-info { - background: rgba(66, 165, 245, 0.12); - color: var(--info); + background: rgba(66, 165, 245, 0.12); + color: var(--info); } .confirm-dialog-message { - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: var(--leading-relaxed); - max-width: 300px; - margin: 0 auto; + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-relaxed); + max-width: 300px; + margin: 0 auto; } /* ----------------------------------------------------------------------- Host key verification dialog ----------------------------------------------------------------------- */ .hostkey-dialog { - width: 520px; + width: 520px; } .hostkey-fingerprint { - font-family: var(--font-mono); - font-size: var(--text-sm); - color: var(--text-primary); - background: var(--bg-input); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-md); - border: 1px solid var(--border-subtle); - word-break: break-all; - line-height: var(--leading-relaxed); - margin: var(--space-3) 0; + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--text-primary); + background: var(--bg-input); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + word-break: break-all; + line-height: var(--leading-relaxed); + margin: var(--space-3) 0; } .hostkey-fingerprint-label { - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: var(--space-1); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-1); } .hostkey-mismatch-warning { - display: flex; - align-items: flex-start; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - background: rgba(239, 83, 80, 0.08); - border: 1px solid rgba(239, 83, 80, 0.20); - border-radius: var(--radius-md); - margin: var(--space-3) 0; + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: rgba(239, 83, 80, 0.08); + border: 1px solid rgba(239, 83, 80, 0.2); + border-radius: var(--radius-md); + margin: var(--space-3) 0; } .hostkey-mismatch-warning .warning-icon { - font-size: var(--text-lg); - color: var(--error); - flex-shrink: 0; - line-height: 1; - margin-top: 2px; + font-size: var(--text-lg); + color: var(--error); + flex-shrink: 0; + line-height: 1; + margin-top: 2px; } .hostkey-mismatch-warning p { - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: var(--leading-relaxed); + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-relaxed); } /* ----------------------------------------------------------------------- Settings modal ----------------------------------------------------------------------- */ .settings-modal { - width: 640px; - max-height: 80vh; + width: 640px; + max-height: 80vh; } .settings-nav { - display: flex; - gap: 1px; - background: var(--border-subtle); - border-radius: var(--radius-md); - overflow: hidden; - margin-bottom: var(--space-4); + display: flex; + gap: 1px; + background: var(--border-subtle); + border-radius: var(--radius-md); + overflow: hidden; + margin-bottom: var(--space-4); } .settings-nav-item { - flex: 1; - padding: var(--space-2) var(--space-3); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--text-tertiary); - text-align: center; - background: var(--bg-surface); - border: none; - cursor: pointer; - transition: background var(--transition-fast), color var(--transition-fast); + flex: 1; + padding: var(--space-2) var(--space-3); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--text-tertiary); + text-align: center; + background: var(--bg-surface); + border: none; + cursor: pointer; + transition: + background var(--transition-fast), + color var(--transition-fast); } .settings-nav-item:hover { - color: var(--text-secondary); + color: var(--text-secondary); } .settings-nav-item.active { - background: var(--bg-active); - color: var(--text-primary); + background: var(--bg-active); + color: var(--text-primary); } .settings-section { - margin-bottom: var(--space-6); + margin-bottom: var(--space-6); } .settings-section-title { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--text-primary); - margin-bottom: var(--space-3); - padding-bottom: var(--space-2); - border-bottom: 1px solid var(--border-subtle); + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--text-primary); + margin-bottom: var(--space-3); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-subtle); } /* ----------------------------------------------------------------------- Known hosts viewer ----------------------------------------------------------------------- */ .known-hosts-modal { - width: 700px; + width: 700px; } .known-hosts-list { - display: flex; - flex-direction: column; - gap: 1px; + display: flex; + flex-direction: column; + gap: 1px; } .known-hosts-item { - display: grid; - grid-template-columns: 1fr 140px 60px; - gap: var(--space-3); - align-items: center; - padding: var(--space-2) var(--space-3); - font-size: var(--text-sm); - background: var(--bg-surface); - border-radius: var(--radius-sm); - transition: background var(--transition-fast); + display: grid; + grid-template-columns: 1fr 140px 60px; + gap: var(--space-3); + align-items: center; + padding: var(--space-2) var(--space-3); + font-size: var(--text-sm); + background: var(--bg-surface); + border-radius: var(--radius-sm); + transition: background var(--transition-fast); } .known-hosts-item:hover { - background: var(--bg-hover); + background: var(--bg-hover); } .known-hosts-hostname { - font-family: var(--font-mono); - font-weight: var(--weight-medium); - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-family: var(--font-mono); + font-weight: var(--weight-medium); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .known-hosts-type { - font-size: var(--text-xs); - color: var(--text-tertiary); + font-size: var(--text-xs); + color: var(--text-tertiary); } .known-hosts-actions { - display: flex; - justify-content: flex-end; + display: flex; + justify-content: flex-end; } .known-hosts-empty { - padding: var(--space-8); - text-align: center; - font-size: var(--text-sm); - color: var(--text-tertiary); + padding: var(--space-8); + text-align: center; + font-size: var(--text-sm); + color: var(--text-tertiary); } /* ----------------------------------------------------------------------- Quick connect popover ----------------------------------------------------------------------- */ .quick-connect { - width: 360px; + width: 360px; } .quick-connect .modal-body { - padding: var(--space-4); + padding: var(--space-4); } .quick-connect-input { - width: 100%; - height: 36px; - padding: 0 var(--space-3); - font-family: var(--font-mono); - font-size: var(--text-sm); - color: var(--text-primary); - background: var(--bg-input); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); + width: 100%; + height: 36px; + padding: 0 var(--space-3); + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--text-primary); + background: var(--bg-input); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); } .quick-connect-input::placeholder { - color: var(--text-disabled); - font-family: var(--font-sans); + color: var(--text-disabled); + font-family: var(--font-sans); } .quick-connect-input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-muted); - outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-muted); + outline: none; } .quick-connect-help { - font-size: var(--text-xs); - color: var(--text-tertiary); - margin-top: var(--space-2); - line-height: var(--leading-relaxed); + font-size: var(--text-xs); + color: var(--text-tertiary); + margin-top: var(--space-2); + line-height: var(--leading-relaxed); } .quick-connect-help code { - font-size: var(--text-xs); - color: var(--text-secondary); - background: var(--bg-overlay); - padding: 1px 4px; - border-radius: 2px; + font-size: var(--text-xs); + color: var(--text-secondary); + background: var(--bg-overlay); + padding: 1px 4px; + border-radius: 2px; } /* ----------------------------------------------------------------------- Password strength meter ----------------------------------------------------------------------- */ .password-strength-meter { - display: flex; - align-items: center; - gap: var(--space-2); - margin-bottom: var(--space-2); + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); } .strength-bar { - flex: 1; - height: 4px; - background: var(--surface-2); - border-radius: 2px; - overflow: hidden; + flex: 1; + height: 4px; + background: var(--surface-2); + border-radius: 2px; + overflow: hidden; } .strength-fill { - height: 100%; - width: 0; - border-radius: 2px; - transition: width 0.3s ease, background-color 0.3s ease; + height: 100%; + width: 0; + border-radius: 2px; + transition: + width 0.3s ease, + background-color 0.3s ease; } -.strength-fill.strength-weak { background: var(--error); } -.strength-fill.strength-fair { background: var(--warning); } -.strength-fill.strength-good { background: #66bb6a; } -.strength-fill.strength-strong { background: var(--success); } +.strength-fill.strength-weak { + background: var(--error); +} +.strength-fill.strength-fair { + background: var(--warning); +} +.strength-fill.strength-good { + background: #66bb6a; +} +.strength-fill.strength-strong { + background: var(--success); +} .strength-label { - font-size: var(--text-xs); - font-weight: 500; - min-width: 48px; - text-align: right; + font-size: var(--text-xs); + font-weight: 500; + min-width: 48px; + text-align: right; } -.strength-label.strength-weak { color: var(--error); } -.strength-label.strength-fair { color: var(--warning); } -.strength-label.strength-good { color: #66bb6a; } -.strength-label.strength-strong { color: var(--success); } +.strength-label.strength-weak { + color: var(--error); +} +.strength-label.strength-fair { + color: var(--warning); +} +.strength-label.strength-good { + color: #66bb6a; +} +.strength-label.strength-strong { + color: var(--success); +} diff --git a/frontend/src/css/sftp.css b/frontend/src/css/sftp.css index 9d52ea9..902dcab 100644 --- a/frontend/src/css/sftp.css +++ b/frontend/src/css/sftp.css @@ -7,407 +7,415 @@ Panel wrapper — overlays the terminal from the right side ----------------------------------------------------------------------- */ .sftp-panel { - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 300px; - min-width: 220px; - max-width: 50vw; - display: flex; - flex-direction: column; - background: var(--bg-surface); - border-left: 1px solid var(--border-subtle); - overflow: hidden; - z-index: var(--z-dropdown, 50); - box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25); + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 300px; + min-width: 220px; + max-width: 50vw; + display: flex; + flex-direction: column; + background: var(--bg-surface); + border-left: 1px solid var(--border-subtle); + overflow: hidden; + z-index: var(--z-dropdown, 50); + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25); } /* Resize handle — positioned at the left edge of the SFTP panel */ .sftp-resize-handle { - position: absolute; - top: 0; - left: -3px; - bottom: 0; - width: 6px; - cursor: col-resize; - background: transparent; - z-index: var(--z-dropdown, 50); - transition: background var(--transition-fast); + position: absolute; + top: 0; + left: -3px; + bottom: 0; + width: 6px; + cursor: col-resize; + background: transparent; + z-index: var(--z-dropdown, 50); + transition: background var(--transition-fast); } .sftp-resize-handle:hover, .sftp-resize-handle.is-dragging { - background: var(--accent); + background: var(--accent); } /* ----------------------------------------------------------------------- Toolbar (top bar with path breadcrumb + action buttons) ----------------------------------------------------------------------- */ .sftp-toolbar { - display: flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-2); - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-elevated); - flex-shrink: 0; - min-height: 34px; + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-elevated); + flex-shrink: 0; + min-height: 34px; } .sftp-toolbar-actions { - display: flex; - align-items: center; - gap: 2px; - flex-shrink: 0; + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; } .sftp-toolbar-btn { - width: 26px; - height: 26px; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: none; - color: var(--text-tertiary); - border-radius: var(--radius-sm); - cursor: pointer; - font-size: var(--text-sm); - transition: background var(--transition-fast), color var(--transition-fast); - flex-shrink: 0; - padding: 0; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--text-tertiary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--text-sm); + transition: + background var(--transition-fast), + color var(--transition-fast); + flex-shrink: 0; + padding: 0; } .sftp-toolbar-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); + background: var(--bg-hover); + color: var(--text-primary); } .sftp-toolbar-btn.is-accent:hover { - color: var(--accent); + color: var(--accent); } .sftp-toolbar-btn.is-close:hover { - color: var(--error); + color: var(--error); } /* ----------------------------------------------------------------------- Breadcrumb path ----------------------------------------------------------------------- */ .sftp-breadcrumb { - flex: 1; - display: flex; - align-items: center; - min-width: 0; - overflow: hidden; - font-size: var(--text-xs); - color: var(--text-tertiary); - gap: 1px; + flex: 1; + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + font-size: var(--text-xs); + color: var(--text-tertiary); + gap: 1px; } .sftp-breadcrumb-segment { - background: none; - border: none; - color: var(--text-secondary); - font-size: var(--text-xs); - cursor: pointer; - padding: 2px 4px; - border-radius: var(--radius-sm); - white-space: nowrap; - transition: background var(--transition-fast), color var(--transition-fast); - max-width: 100px; - overflow: hidden; - text-overflow: ellipsis; + background: none; + border: none; + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + padding: 2px 4px; + border-radius: var(--radius-sm); + white-space: nowrap; + transition: + background var(--transition-fast), + color var(--transition-fast); + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; } .sftp-breadcrumb-segment:hover { - background: var(--bg-hover); - color: var(--text-primary); + background: var(--bg-hover); + color: var(--text-primary); } .sftp-breadcrumb-segment:last-child { - color: var(--text-primary); - font-weight: var(--weight-medium); + color: var(--text-primary); + font-weight: var(--weight-medium); } .sftp-breadcrumb-sep { - color: var(--text-disabled); - font-size: 10px; - flex-shrink: 0; - user-select: none; + color: var(--text-disabled); + font-size: 10px; + flex-shrink: 0; + user-select: none; } /* ----------------------------------------------------------------------- File list ----------------------------------------------------------------------- */ .sftp-file-list { - flex: 1; - overflow-y: auto; - overflow-x: hidden; + flex: 1; + overflow-y: auto; + overflow-x: hidden; } /* Scrollbar styling */ .sftp-file-list::-webkit-scrollbar { - width: 6px; + width: 6px; } .sftp-file-list::-webkit-scrollbar-track { - background: transparent; + background: transparent; } .sftp-file-list::-webkit-scrollbar-thumb { - background: var(--border-strong, var(--bg-active)); - border-radius: 3px; + background: var(--border-strong, var(--bg-active)); + border-radius: 3px; } .sftp-file-list::-webkit-scrollbar-thumb:hover { - background: var(--text-tertiary); + background: var(--text-tertiary); } /* ----------------------------------------------------------------------- File row ----------------------------------------------------------------------- */ .sftp-file-row { - display: grid; - grid-template-columns: 20px 1fr auto; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-2); - cursor: pointer; - transition: background var(--transition-fast); - user-select: none; - min-height: 30px; + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + cursor: pointer; + transition: background var(--transition-fast); + user-select: none; + min-height: 30px; } .sftp-file-row:hover { - background: var(--bg-hover); + background: var(--bg-hover); } .sftp-file-row.is-selected { - background: var(--accent-muted); + background: var(--accent-muted); } .sftp-file-row.is-selected:hover { - background: var(--accent-strong); + background: var(--accent-strong); } .sftp-file-row.is-readonly .sftp-file-meta { - color: var(--warning); - font-weight: var(--weight-semibold); + color: var(--warning); + font-weight: var(--weight-semibold); } .sftp-file-row.is-unreadable { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .sftp-file-row.is-unreadable .sftp-file-name { - text-decoration: line-through; + text-decoration: line-through; } .sftp-file-icon { - font-size: 13px; - text-align: center; - line-height: 1; - flex-shrink: 0; - color: var(--text-tertiary); + font-size: 13px; + text-align: center; + line-height: 1; + flex-shrink: 0; + color: var(--text-tertiary); } .sftp-file-icon.is-dir { - color: var(--accent); + color: var(--accent); } .sftp-file-name { - font-size: var(--text-sm); - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; + font-size: var(--text-sm); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .sftp-file-meta { - font-size: var(--text-xs); - color: var(--text-tertiary); - white-space: nowrap; - text-align: right; + font-size: var(--text-xs); + color: var(--text-tertiary); + white-space: nowrap; + text-align: right; } /* ----------------------------------------------------------------------- Parent directory row (..) ----------------------------------------------------------------------- */ .sftp-file-row.is-parent .sftp-file-name { - color: var(--text-secondary); - font-style: italic; + color: var(--text-secondary); + font-style: italic; } /* ----------------------------------------------------------------------- Context actions bar (shown below file list when a file is selected) ----------------------------------------------------------------------- */ .sftp-context-bar { - display: flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-2); - border-top: 1px solid var(--border-subtle); - background: var(--bg-elevated); - flex-shrink: 0; - min-height: 32px; + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-top: 1px solid var(--border-subtle); + background: var(--bg-elevated); + flex-shrink: 0; + min-height: 32px; } .sftp-context-bar .sftp-ctx-label { - flex: 1; - font-size: var(--text-xs); - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; + flex: 1; + font-size: var(--text-xs); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .sftp-context-bar .sftp-ctx-btn { - height: 24px; - padding: 0 var(--space-2); - font-size: var(--text-xs); - font-weight: var(--weight-medium); - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - background: transparent; - color: var(--text-secondary); - transition: background var(--transition-fast), color var(--transition-fast); - white-space: nowrap; + height: 24px; + padding: 0 var(--space-2); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + background: transparent; + color: var(--text-secondary); + transition: + background var(--transition-fast), + color var(--transition-fast); + white-space: nowrap; } .sftp-context-bar .sftp-ctx-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); + background: var(--bg-hover); + color: var(--text-primary); } .sftp-context-bar .sftp-ctx-btn.is-danger:hover { - background: rgba(239, 83, 80, 0.12); - color: var(--error); + background: rgba(239, 83, 80, 0.12); + color: var(--error); } .sftp-context-bar .sftp-ctx-btn.is-accent:hover { - color: var(--accent); + color: var(--accent); } /* ----------------------------------------------------------------------- States: loading, empty, error ----------------------------------------------------------------------- */ .sftp-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--space-6) var(--space-4); - text-align: center; - gap: var(--space-2); + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-6) var(--space-4); + text-align: center; + gap: var(--space-2); } .sftp-state-icon { - font-size: 28px; - color: var(--text-disabled); + font-size: 28px; + color: var(--text-disabled); } .sftp-state-msg { - font-size: var(--text-sm); - color: var(--text-tertiary); - line-height: var(--leading-relaxed); - max-width: 200px; + font-size: var(--text-sm); + color: var(--text-tertiary); + line-height: var(--leading-relaxed); + max-width: 200px; } .sftp-state-retry { - margin-top: var(--space-1); + margin-top: var(--space-1); } /* Loading spinner (pure CSS) */ .sftp-spinner { - width: 20px; - height: 20px; - border: 2px solid var(--border-subtle); - border-top-color: var(--accent); - border-radius: 50%; - animation: sftp-spin 0.6s linear infinite; + width: 20px; + height: 20px; + border: 2px solid var(--border-subtle); + border-top-color: var(--accent); + border-radius: 50%; + animation: sftp-spin 0.6s linear infinite; } @keyframes sftp-spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* ----------------------------------------------------------------------- Inline input (for rename / mkdir) ----------------------------------------------------------------------- */ .sftp-inline-input { - width: 100%; - height: 26px; - padding: 0 var(--space-2); - font-size: var(--text-sm); - font-family: inherit; - color: var(--text-primary); - background: var(--bg-input); - border: 1px solid var(--accent); - border-radius: var(--radius-sm); - outline: none; - box-shadow: 0 0 0 2px var(--accent-muted); + width: 100%; + height: 26px; + padding: 0 var(--space-2); + font-size: var(--text-sm); + font-family: inherit; + color: var(--text-primary); + background: var(--bg-input); + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + outline: none; + box-shadow: 0 0 0 2px var(--accent-muted); } /* Picker-specific input in the toolbar */ .sftp-picker-filter { - width: 160px; - height: 26px; - padding: 0 var(--space-2); - font-size: var(--text-xs); - border: 1px solid var(--border-default); - border-radius: var(--radius-sm); - background: var(--bg-input); - color: var(--text-primary); + width: 160px; + height: 26px; + padding: 0 var(--space-2); + font-size: var(--text-xs); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--bg-input); + color: var(--text-primary); } .sftp-picker-filter:focus { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-muted); - outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-muted); + outline: none; } .sftp-picker-status { - font-size: var(--text-xs); - color: var(--text-tertiary); - margin-right: var(--space-2); + font-size: var(--text-xs); + color: var(--text-tertiary); + margin-right: var(--space-2); } .sftp-picker-badge { - display: inline-flex; - align-items: center; - justify-content: center; - height: 18px; - padding: 0 var(--space-2); - border-radius: var(--radius-sm); - font-size: 10px; - font-weight: var(--weight-semibold); - text-transform: uppercase; - letter-spacing: 0.04em; - border: 1px solid var(--border-default); - background: var(--bg-surface); - color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + height: 18px; + padding: 0 var(--space-2); + border-radius: var(--radius-sm); + font-size: 10px; + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + border: 1px solid var(--border-default); + background: var(--bg-surface); + color: var(--text-tertiary); } .sftp-picker-badge.is-readonly { - border-color: rgba(255, 167, 38, 0.35); - color: var(--warning); - background: rgba(255, 167, 38, 0.12); + border-color: rgba(255, 167, 38, 0.35); + color: var(--warning); + background: rgba(255, 167, 38, 0.12); } .sftp-picker-badge.is-noread { - border-color: rgba(239, 83, 80, 0.35); - color: var(--error); - background: rgba(239, 83, 80, 0.12); + border-color: rgba(239, 83, 80, 0.35); + color: var(--error); + background: rgba(239, 83, 80, 0.12); } .sftp-picker-legend { - display: inline-flex; - align-items: center; - gap: var(--space-1); - font-size: 10px; - color: var(--text-tertiary); + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: 10px; + color: var(--text-tertiary); } diff --git a/frontend/src/css/terminal.css b/frontend/src/css/terminal.css index cb92ae6..51cb3cd 100644 --- a/frontend/src/css/terminal.css +++ b/frontend/src/css/terminal.css @@ -65,6 +65,60 @@ padding: 0; } +/* ----------------------------------------------------------------------- + Disconnected overlay + ----------------------------------------------------------------------- */ +.session-panel.is-disconnected > *:not(.terminal-disconnect-overlay) { + filter: blur(2px); + pointer-events: none; +} + +.terminal-disconnect-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(10, 14, 18, 0.68); + z-index: 6; + opacity: 0; + visibility: hidden; + transition: opacity var(--transition-fast), visibility var(--transition-fast); +} + +.terminal-disconnect-overlay.is-visible { + opacity: 1; + visibility: visible; +} + +.terminal-disconnect-content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + text-align: center; + padding: var(--space-4) var(--space-5); + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); +} + +.terminal-disconnect-title { + font-size: var(--text-lg); + letter-spacing: 0.45em; + text-indent: 0.45em; + text-transform: uppercase; + color: var(--text-primary); + font-weight: var(--weight-semibold); +} + +.terminal-disconnect-message { + font-size: var(--text-sm); + color: var(--text-secondary); + max-width: 360px; +} + /* xterm.js overrides for consistent look */ .terminal-container .xterm, .session-panel .xterm { diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index feb03a4..958ff23 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -168,6 +168,14 @@ ); this.updateStatusBar(); + const showDisconnectedOverlay = + payload.state === "disconnected" || payload.state === "error"; + this.terminals.setDisconnectedOverlay( + payload.sessionID, + showDisconnectedOverlay, + payload.error, + ); + if (payload.state === "error" && payload.error) { this._handleSessionError(payload.sessionID, payload.error); } diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index 793e4e6..c84db8d 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -82,6 +82,15 @@ /** @type {boolean} */ this._isNew = false; + + /** @type {string[]} */ + this._sshKeyOptions = []; + + /** @type {boolean} */ + this._sshKeyOptionsLoaded = false; + + /** @type {boolean} */ + this._sshKeyOptionsLoading = false; } // ── Public API ─────────────────────────────────────────────────── @@ -151,6 +160,7 @@ // ── Scrollable body ───────────────────────────────────── const body = createElement("div", "drawer-body"); + const sshKeyDatalist = this._buildSSHKeyDatalist(); // General section (common to all protocols) body.appendChild(this._renderGeneralSection(profile)); @@ -159,6 +169,7 @@ switch (profile.protocol) { case "ssh": this._renderSSHSections(profile).forEach((s) => body.appendChild(s)); + body.appendChild(sshKeyDatalist); break; case "telnet": this._renderTelnetSections(profile).forEach((s) => @@ -240,6 +251,8 @@ // Focus the first input const firstInput = drawer.querySelector("input, select, textarea"); if (firstInput) firstInput.focus(); + + this._loadSSHKeyOptions(); } /** @@ -377,6 +390,42 @@ return group; } + _buildSSHKeyDatalist() { + const list = createElement("datalist", "", { + id: "ssh-key-path-options", + }); + this._applySSHKeyOptionsToList(list); + return list; + } + + _applySSHKeyOptionsToList(listEl) { + if (!listEl) return; + listEl.innerHTML = ""; + for (const keyPath of this._sshKeyOptions) { + const opt = createElement("option", "", { value: keyPath }); + listEl.appendChild(opt); + } + } + + _loadSSHKeyOptions() { + if (this._sshKeyOptionsLoading || this._sshKeyOptionsLoaded) return; + if (!window.go || !window.go.api || !window.go.api.Service) return; + + this._sshKeyOptionsLoading = true; + window.go.api.Service.ListSSHKeys() + .then((keys) => { + this._sshKeyOptions = Array.isArray(keys) ? keys : []; + this._sshKeyOptionsLoaded = true; + this._sshKeyOptionsLoading = false; + if (!this.drawer) return; + const listEl = this.drawer.querySelector("#ssh-key-path-options"); + this._applySSHKeyOptionsToList(listEl); + }) + .catch(() => { + this._sshKeyOptionsLoading = false; + }); + } + /** @private */ _renderSSHSections(profile) { const ssh = profile.ssh || {}; @@ -413,15 +462,7 @@ // Key & Passphrase sections.push( this._renderSection("Key & Passphrase", [ - this._field( - "text", - "ssh.privateKeyPath", - "Private Key Path", - ssh.privateKeyPath, - { - placeholder: "~/.ssh/id_ed25519", - }, - ), + this._renderSSHKeyPathField(ssh.privateKeyPath), this._field( "select", "ssh.keySecretMode", @@ -1522,6 +1563,10 @@ group.appendChild(input); + if (opts.datalistId && input && input.tagName === "INPUT") { + input.setAttribute("list", opts.datalistId); + } + // Help text if (opts.help) { group.appendChild( @@ -1532,6 +1577,64 @@ return group; } + _renderSSHKeyPathField(value) { + const group = createElement("div", "form-group"); + const labelEl = createElement("label", "form-label", { + textContent: "Private Key Path", + for: "field-ssh.privateKeyPath", + }); + group.appendChild(labelEl); + + const row = createElement("div", "key-path-row"); + const input = createElement("input", "form-input", { + type: "text", + id: "field-ssh.privateKeyPath", + name: "ssh.privateKeyPath", + value: value || "", + placeholder: "/Users/username/.ssh/id_ed25519", + }); + input.setAttribute("list", "ssh-key-path-options"); + row.appendChild(input); + + const browseBtn = createElement("button", "btn btn-sm btn-outline", { + textContent: "Browse", + type: "button", + }); + browseBtn.addEventListener("mousedown", (e) => e.preventDefault()); + browseBtn.addEventListener("click", () => { + if (!window.go || !window.go.api || !window.go.api.Service) return; + window.go.api.Service.SelectFile("Select SSH Private Key", "*") + .then((path) => { + if (path) input.value = path; + }) + .catch((err) => { + this.app.notifications.error( + "Failed to open file dialog: " + (err.message || err), + ); + }); + }); + row.appendChild(browseBtn); + group.appendChild(row); + + const error = createElement("span", "form-error"); + error.style.display = "none"; + group.appendChild(error); + + const success = createElement("span", "form-success"); + success.style.display = "none"; + group.appendChild(success); + + this._attachKeyPathValidation(input, error, success); + + group.appendChild( + createElement("span", "form-hint", { + textContent: "Select a key from your .ssh folder or type a full path", + }), + ); + + return group; + } + /** * Renders the jump-hosts list editor. * @param {Array} jumpHosts @@ -1551,15 +1654,41 @@
- + +
`; + const keyInput = row.querySelector(`input[name="jumpHost-${i}-privateKeyPath"]`); + const browseBtn = row.querySelector(".jump-host-browse"); + const error = createElement("span", "form-error"); + error.style.display = "none"; + const success = createElement("span", "form-success"); + success.style.display = "none"; + + browseBtn.addEventListener("mousedown", (e) => e.preventDefault()); + browseBtn.addEventListener("click", () => { + if (!window.go || !window.go.api || !window.go.api.Service) return; + window.go.api.Service.SelectFile("Select SSH Private Key", "*") + .then((path) => { + if (path) keyInput.value = path; + this._validateKeyPath(keyInput, error); + }) + .catch((err) => { + this.app.notifications.error( + "Failed to open file dialog: " + (err.message || err), + ); + }); + }); + + this._attachKeyPathValidation(keyInput, error, success); row.querySelector(".jump-host-remove").addEventListener("click", () => { jumpHosts.splice(i, 1); this.render(this._profile, this._isNew); }); wrapper.appendChild(row); + wrapper.appendChild(error); + wrapper.appendChild(success); } const addBtn = createElement("button", "btn btn-sm btn-outline", { @@ -1581,6 +1710,82 @@ return wrapper; } + _attachKeyPathValidation(input, errorEl, successEl) { + if (!input || !errorEl) return; + input.addEventListener("blur", () => this._validateKeyPath(input, errorEl, successEl)); + input.addEventListener("input", () => { + errorEl.style.display = "none"; + errorEl.textContent = ""; + if (successEl) { + successEl.style.display = "none"; + successEl.textContent = ""; + } + }); + } + + _validateKeyPath(input, errorEl, successEl) { + const value = (input.value || "").trim(); + if (!value) { + errorEl.style.display = "none"; + errorEl.textContent = ""; + if (successEl) { + successEl.style.display = "none"; + successEl.textContent = ""; + } + return; + } + if (value.startsWith("~")) { + errorEl.textContent = "Please use a full path (no ~)."; + errorEl.style.display = "block"; + if (successEl) { + successEl.style.display = "none"; + successEl.textContent = ""; + } + return; + } + if (!window.go || !window.go.api || !window.go.api.Service) { + return; + } + window.go.api.Service.FileExists(value) + .then((exists) => { + if (!exists) { + errorEl.textContent = "Key file not found."; + errorEl.style.display = "block"; + if (successEl) { + successEl.style.display = "none"; + successEl.textContent = ""; + } + return; + } + return window.go.api.Service.FileIsPrivateKey(value) + .then((isKey) => { + if (!isKey) { + errorEl.textContent = "File is not a recognised private key."; + errorEl.style.display = "block"; + if (successEl) { + successEl.style.display = "none"; + successEl.textContent = ""; + } + return; + } + errorEl.style.display = "none"; + errorEl.textContent = ""; + if (successEl) { + successEl.textContent = "Key verified."; + successEl.style.display = "block"; + } + }); + }) + .catch(() => { + errorEl.textContent = "Could not verify this path."; + errorEl.style.display = "block"; + if (successEl) { + successEl.style.display = "none"; + successEl.textContent = ""; + } + }); + } + /** * Renders the port-forwarding rules list editor. * @param {Array} portForwards diff --git a/frontend/src/js/sftp.js b/frontend/src/js/sftp.js index 72b06a9..dffee06 100644 --- a/frontend/src/js/sftp.js +++ b/frontend/src/js/sftp.js @@ -1,77 +1,127 @@ -(function() { -/** - * Nexus Terminal — SFTP File Browser - * - * Provides a right-side panel for browsing, uploading, downloading, - * renaming and deleting remote files alongside the terminal. - */ +(function () { + /** + * Nexus Terminal — SFTP File Browser + * + * Provides a right-side panel for browsing, uploading, downloading, + * renaming and deleting remote files alongside the terminal. + */ -const { - createElement, escapeHtml, formatBytes, relativeTime, dismissContextMenus, -} = window.NexusTerminal; + const { + createElement, + escapeHtml, + formatBytes, + relativeTime, + dismissContextMenus, + } = window.NexusTerminal; -/* ── Helpers ──────────────────────────────────────────────────────── */ + /* ── Helpers ──────────────────────────────────────────────────────── */ -function pathJoin(base, name) { - if (base === '/') return '/' + name; - return base.replace(/\/+$/, '') + '/' + name; -} + function pathJoin(base, name) { + if (base === "/") return "/" + name; + return base.replace(/\/+$/, "") + "/" + name; + } -function pathSegments(p) { - const parts = p.split('/').filter(Boolean); - var segs = [{ label: '/', path: '/' }]; - var acc = ''; + function pathSegments(p) { + const parts = p.split("/").filter(Boolean); + var segs = [{ label: "/", path: "/" }]; + var acc = ""; for (var i = 0; i < parts.length; i++) { - acc += '/' + parts[i]; - segs.push({ label: parts[i], path: acc }); + acc += "/" + parts[i]; + segs.push({ label: parts[i], path: acc }); } return segs; -} + } -function parentPath(p) { - if (p === '/') return '/'; - var trimmed = p.replace(/\/+$/, ''); - var idx = trimmed.lastIndexOf('/'); - return idx <= 0 ? '/' : trimmed.substring(0, idx); -} + function parentPath(p) { + if (p === "/") return "/"; + var trimmed = p.replace(/\/+$/, ""); + var idx = trimmed.lastIndexOf("/"); + return idx <= 0 ? "/" : trimmed.substring(0, idx); + } -function fileIcon(entry) { - if (entry.isDir) return '📁'; - var ext = (entry.name.split('.').pop() || '').toLowerCase(); + function fileIcon(entry) { + if (entry.isDir) return "📁"; + var ext = (entry.name.split(".").pop() || "").toLowerCase(); switch (ext) { - case 'jpg': case 'jpeg': case 'png': case 'gif': case 'svg': case 'webp': return '🖼'; - case 'mp3': case 'wav': case 'ogg': case 'flac': return '🎵'; - case 'mp4': case 'avi': case 'mkv': case 'mov': return '🎬'; - case 'zip': case 'tar': case 'gz': case 'bz2': case 'xz': case '7z': return '📦'; - case 'sh': case 'bash': case 'zsh': return '⚙'; - case 'py': case 'js': case 'ts': case 'go': case 'rs': case 'c': case 'h': case 'cpp': case 'java': return '◇'; - case 'md': case 'txt': case 'log': case 'csv': return '📝'; - case 'json': case 'yaml': case 'yml': case 'toml': case 'xml': return '⚙'; - case 'key': case 'pem': case 'crt': case 'pub': return '🔑'; - default: return '📄'; + case "jpg": + case "jpeg": + case "png": + case "gif": + case "svg": + case "webp": + return "🖼"; + case "mp3": + case "wav": + case "ogg": + case "flac": + return "🎵"; + case "mp4": + case "avi": + case "mkv": + case "mov": + return "🎬"; + case "zip": + case "tar": + case "gz": + case "bz2": + case "xz": + case "7z": + return "📦"; + case "sh": + case "bash": + case "zsh": + return "⚙"; + case "py": + case "js": + case "ts": + case "go": + case "rs": + case "c": + case "h": + case "cpp": + case "java": + return "◇"; + case "md": + case "txt": + case "log": + case "csv": + return "📝"; + case "json": + case "yaml": + case "yml": + case "toml": + case "xml": + return "⚙"; + case "key": + case "pem": + case "crt": + case "pub": + return "🔑"; + default: + return "📄"; } -} + } -function modeString(mode) { - if (!mode && mode !== 0) return ''; - var s = ''; - var perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']; + function modeString(mode) { + if (!mode && mode !== 0) return ""; + var s = ""; + var perms = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"]; s += perms[(mode >> 6) & 7]; s += perms[(mode >> 3) & 7]; s += perms[mode & 7]; return s; -} + } -/* ── SFTPBrowser class ────────────────────────────────────────────── */ + /* ── SFTPBrowser class ────────────────────────────────────────────── */ -class SFTPBrowser { + class SFTPBrowser { /** * @param {App} app — the application instance */ constructor(app) { - this.app = app; - /** @type {Map} */ - this.panels = new Map(); + this.app = app; + /** @type {Map} */ + this.panels = new Map(); } /* ── Public API ───────────────────────────────────────────────── */ @@ -81,55 +131,57 @@ class SFTPBrowser { * @param {string} sessionId */ async open(sessionId) { - if (this.panels.has(sessionId)) return; + if (this.panels.has(sessionId)) return; - var sessionPanel = document.getElementById('panel-' + sessionId); - if (!sessionPanel) { - this.app.notifications.error('Cannot open file browser: session panel not found.'); - return; - } + var sessionPanel = document.getElementById("panel-" + sessionId); + if (!sessionPanel) { + this.app.notifications.error( + "Cannot open file browser: session panel not found.", + ); + return; + } - var state = { - el: null, - handle: null, - cwd: '/', - files: [], - selected: null, - loading: false, - }; + var state = { + el: null, + handle: null, + cwd: "/", + files: [], + selected: null, + loading: false, + }; - // Build the panel DOM - var panel = createElement('div', 'sftp-panel'); - var handle = createElement('div', 'sftp-resize-handle'); - panel.appendChild(handle); - state.el = panel; - state.handle = handle; + // Build the panel DOM + var panel = createElement("div", "sftp-panel"); + var handle = createElement("div", "sftp-resize-handle"); + panel.appendChild(handle); + state.el = panel; + state.handle = handle; - // Toolbar - var toolbar = this._buildToolbar(sessionId, state); - panel.appendChild(toolbar); + // Toolbar + var toolbar = this._buildToolbar(sessionId, state); + panel.appendChild(toolbar); - // File list container - var fileList = createElement('div', 'sftp-file-list'); - panel.appendChild(fileList); + // File list container + var fileList = createElement("div", "sftp-file-list"); + panel.appendChild(fileList); - // Context action bar (hidden initially) - var ctxBar = createElement('div', 'sftp-context-bar'); - ctxBar.style.display = 'none'; - panel.appendChild(ctxBar); + // Context action bar (hidden initially) + var ctxBar = createElement("div", "sftp-context-bar"); + ctxBar.style.display = "none"; + panel.appendChild(ctxBar); - this.panels.set(sessionId, state); + this.panels.set(sessionId, state); - // Append the overlay panel into the session panel - sessionPanel.appendChild(panel); + // Append the overlay panel into the session panel + sessionPanel.appendChild(panel); - this._setupResize(sessionId, state); + this._setupResize(sessionId, state); - // Fetch initial directory - await this._navigate(sessionId, null); + // Fetch initial directory + await this._navigate(sessionId, null); - // Refit terminal after layout change - this._refitTerminal(sessionId); + // Refit terminal after layout change + this._refitTerminal(sessionId); } /** @@ -137,13 +189,13 @@ class SFTPBrowser { * @param {string} sessionId */ close(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; + var state = this.panels.get(sessionId); + if (!state) return; - state.el.remove(); + state.el.remove(); - this.panels.delete(sessionId); - this._refitTerminal(sessionId); + this.panels.delete(sessionId); + this._refitTerminal(sessionId); } /** @@ -151,11 +203,11 @@ class SFTPBrowser { * @param {string} sessionId */ toggle(sessionId) { - if (this.panels.has(sessionId)) { - this.close(sessionId); - } else { - this.open(sessionId); - } + if (this.panels.has(sessionId)) { + this.close(sessionId); + } else { + this.open(sessionId); + } } /** @@ -164,44 +216,44 @@ class SFTPBrowser { * @returns {string} */ getCwd(sessionId) { - var state = this.panels.get(sessionId); - return state ? state.cwd : ''; + var state = this.panels.get(sessionId); + return state ? state.cwd : ""; } /* ── Toolbar ──────────────────────────────────────────────────── */ _buildToolbar(sessionId, state) { - var toolbar = createElement('div', 'sftp-toolbar'); + var toolbar = createElement("div", "sftp-toolbar"); - var breadcrumb = createElement('div', 'sftp-breadcrumb'); - breadcrumb.dataset.role = 'breadcrumb'; - toolbar.appendChild(breadcrumb); + var breadcrumb = createElement("div", "sftp-breadcrumb"); + breadcrumb.dataset.role = "breadcrumb"; + toolbar.appendChild(breadcrumb); - var actions = createElement('div', 'sftp-toolbar-actions'); + var actions = createElement("div", "sftp-toolbar-actions"); - var mkdirBtn = this._toolbarBtn('+', 'New folder', 'is-accent'); - mkdirBtn.addEventListener('click', () => this._promptMkdir(sessionId)); - actions.appendChild(mkdirBtn); + var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent"); + mkdirBtn.addEventListener("click", () => this._promptMkdir(sessionId)); + actions.appendChild(mkdirBtn); - var uploadBtn = this._toolbarBtn('↑', 'Upload file', 'is-accent'); - uploadBtn.addEventListener('click', () => this._upload(sessionId)); - actions.appendChild(uploadBtn); + var uploadBtn = this._toolbarBtn("↑", "Upload file", "is-accent"); + uploadBtn.addEventListener("click", () => this._upload(sessionId)); + actions.appendChild(uploadBtn); - var refreshBtn = this._toolbarBtn('⟳', 'Refresh'); - refreshBtn.addEventListener('click', () => this._refresh(sessionId)); - actions.appendChild(refreshBtn); + var refreshBtn = this._toolbarBtn("⟳", "Refresh"); + refreshBtn.addEventListener("click", () => this._refresh(sessionId)); + actions.appendChild(refreshBtn); - var closeBtn = this._toolbarBtn('✕', 'Close', 'is-close'); - closeBtn.addEventListener('click', () => this.close(sessionId)); - actions.appendChild(closeBtn); + var closeBtn = this._toolbarBtn("✕", "Close", "is-close"); + closeBtn.addEventListener("click", () => this.close(sessionId)); + actions.appendChild(closeBtn); - toolbar.appendChild(actions); - return toolbar; + toolbar.appendChild(actions); + return toolbar; } _toolbarBtn(label, title, extraClass) { - var cls = 'sftp-toolbar-btn' + (extraClass ? ' ' + extraClass : ''); - return createElement('button', cls, { textContent: label, title: title }); + var cls = "sftp-toolbar-btn" + (extraClass ? " " + extraClass : ""); + return createElement("button", cls, { textContent: label, title: title }); } /* ── Navigation ───────────────────────────────────────────────── */ @@ -210,492 +262,585 @@ class SFTPBrowser { * Navigates to a directory path. If path is null, fetches pwd first. */ async _navigate(sessionId, path) { - var state = this.panels.get(sessionId); - if (!state || state.loading) return; + var state = this.panels.get(sessionId); + if (!state || state.loading) return; - state.loading = true; - state.selected = null; - this._renderLoading(sessionId); + state.loading = true; + state.selected = null; + this._renderLoading(sessionId); - try { - if (path === null) { - path = await window.go.api.Service.SFTPPwd(sessionId); - } - var files = await window.go.api.Service.SFTPList(sessionId, path); - state.cwd = path; - state.files = this._sortFiles(files || []); - state.loading = false; - this._renderBreadcrumb(sessionId); - this._renderFiles(sessionId); - } catch (err) { - state.loading = false; - this._renderError(sessionId, err.message || String(err)); + try { + if (path === null) { + path = await window.go.api.Service.SFTPPwd(sessionId); } + var files = await window.go.api.Service.SFTPList(sessionId, path); + state.cwd = path; + state.files = this._sortFiles(files || []); + state.loading = false; + this._renderBreadcrumb(sessionId); + this._renderFiles(sessionId); + } catch (err) { + state.loading = false; + this._renderError(sessionId, err.message || String(err)); + } } async _refresh(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - await this._navigate(sessionId, state.cwd); + var state = this.panels.get(sessionId); + if (!state) return; + await this._navigate(sessionId, state.cwd); } _sortFiles(files) { - return files.slice().sort(function(a, b) { - // Directories first - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); - }); + return files.slice().sort(function (a, b) { + // Directories first + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); } /* ── Rendering ────────────────────────────────────────────────── */ _getFileList(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return null; - return state.el.querySelector('.sftp-file-list'); + var state = this.panels.get(sessionId); + if (!state) return null; + return state.el.querySelector(".sftp-file-list"); } _getCtxBar(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return null; - return state.el.querySelector('.sftp-context-bar'); + var state = this.panels.get(sessionId); + if (!state) return null; + return state.el.querySelector(".sftp-context-bar"); } _renderBreadcrumb(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - var container = state.el.querySelector('[data-role="breadcrumb"]'); - container.innerHTML = ''; + var state = this.panels.get(sessionId); + if (!state) return; + var container = state.el.querySelector('[data-role="breadcrumb"]'); + container.innerHTML = ""; - var segs = pathSegments(state.cwd); - var self = this; - for (var i = 0; i < segs.length; i++) { - if (i > 0) { - container.appendChild(createElement('span', 'sftp-breadcrumb-sep', { textContent: '›' })); - } - var btn = createElement('button', 'sftp-breadcrumb-segment', { - textContent: segs[i].label, - title: segs[i].path, - }); - (function(p) { - btn.addEventListener('click', function() { self._navigate(sessionId, p); }); - })(segs[i].path); - container.appendChild(btn); + var segs = pathSegments(state.cwd); + var self = this; + for (var i = 0; i < segs.length; i++) { + if (i > 0) { + container.appendChild( + createElement("span", "sftp-breadcrumb-sep", { textContent: "›" }), + ); } + var btn = createElement("button", "sftp-breadcrumb-segment", { + textContent: segs[i].label, + title: segs[i].path, + }); + (function (p) { + btn.addEventListener("click", function () { + self._navigate(sessionId, p); + }); + })(segs[i].path); + container.appendChild(btn); + } } _renderFiles(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - var list = this._getFileList(sessionId); - list.innerHTML = ''; + var state = this.panels.get(sessionId); + if (!state) return; + var list = this._getFileList(sessionId); + list.innerHTML = ""; - // Hide context bar - var ctxBar = this._getCtxBar(sessionId); - if (ctxBar) ctxBar.style.display = 'none'; + // Hide context bar + var ctxBar = this._getCtxBar(sessionId); + if (ctxBar) ctxBar.style.display = "none"; - // Parent directory row if not at root - if (state.cwd !== '/') { - var parentRow = this._buildFileRow({ - name: '..', - isDir: true, - path: parentPath(state.cwd), - size: 0, - modifiedAt: 0, - }, sessionId, true); - parentRow.classList.add('is-parent'); - list.appendChild(parentRow); - } + // Parent directory row if not at root + if (state.cwd !== "/") { + var parentRow = this._buildFileRow( + { + name: "..", + isDir: true, + path: parentPath(state.cwd), + size: 0, + modifiedAt: 0, + }, + sessionId, + true, + ); + parentRow.classList.add("is-parent"); + list.appendChild(parentRow); + } - if (state.files.length === 0) { - var empty = createElement('div', 'sftp-state'); - empty.innerHTML = '
📂
Empty directory
'; - list.appendChild(empty); - return; - } + if (state.files.length === 0) { + var empty = createElement("div", "sftp-state"); + empty.innerHTML = + '
📂
Empty directory
'; + list.appendChild(empty); + return; + } - var self = this; - for (var i = 0; i < state.files.length; i++) { - list.appendChild(this._buildFileRow(state.files[i], sessionId, false)); - } + var self = this; + for (var i = 0; i < state.files.length; i++) { + list.appendChild(this._buildFileRow(state.files[i], sessionId, false)); + } } _buildFileRow(entry, sessionId, isParent) { - var row = createElement('div', 'sftp-file-row'); - var self = this; + var row = createElement("div", "sftp-file-row"); + var self = this; - // Icon - var icon = createElement('span', 'sftp-file-icon' + (entry.isDir ? ' is-dir' : '')); - icon.textContent = isParent ? '↩' : fileIcon(entry); - row.appendChild(icon); + // Icon + var icon = createElement( + "span", + "sftp-file-icon" + (entry.isDir ? " is-dir" : ""), + ); + icon.textContent = isParent ? "↩" : fileIcon(entry); + row.appendChild(icon); - // Name - var name = createElement('span', 'sftp-file-name', { - textContent: entry.name, - title: entry.name, + // Name + var name = createElement("span", "sftp-file-name", { + textContent: entry.name, + title: entry.name, + }); + row.appendChild(name); + + // Meta (size or date) + var meta = createElement("span", "sftp-file-meta"); + if (!isParent && !entry.isDir) { + meta.textContent = formatBytes(entry.size || 0); + meta.title = entry.modifiedAt + ? new Date(entry.modifiedAt).toLocaleString() + : ""; + } else if (!isParent && entry.isDir) { + meta.textContent = ""; + } + row.appendChild(meta); + + // Double-click: navigate into directory + if (entry.isDir) { + row.addEventListener("dblclick", function () { + var target = isParent + ? parentPath(self.panels.get(sessionId).cwd) + : entry.path; + self._navigate(sessionId, target); }); - row.appendChild(name); + } - // Meta (size or date) - var meta = createElement('span', 'sftp-file-meta'); - if (!isParent && !entry.isDir) { - meta.textContent = formatBytes(entry.size || 0); - meta.title = entry.modifiedAt ? new Date(entry.modifiedAt).toLocaleString() : ''; - } else if (!isParent && entry.isDir) { - meta.textContent = ''; - } - row.appendChild(meta); + // Single click: select (files only, or directories too for context actions) + if (!isParent) { + row.addEventListener("click", function (e) { + e.stopPropagation(); + self._selectFile(sessionId, entry, row); + }); + } - // Double-click: navigate into directory - if (entry.isDir) { - row.addEventListener('dblclick', function() { - var target = isParent ? parentPath(self.panels.get(sessionId).cwd) : entry.path; - self._navigate(sessionId, target); - }); - } - - // Single click: select (files only, or directories too for context actions) - if (!isParent) { - row.addEventListener('click', function(e) { - e.stopPropagation(); - self._selectFile(sessionId, entry, row); - }); - } - - return row; + return row; } _renderLoading(sessionId) { - var list = this._getFileList(sessionId); - if (!list) return; - list.innerHTML = ''; - var s = createElement('div', 'sftp-state'); - s.innerHTML = '
Loading…
'; - list.appendChild(s); - var ctxBar = this._getCtxBar(sessionId); - if (ctxBar) ctxBar.style.display = 'none'; + var list = this._getFileList(sessionId); + if (!list) return; + list.innerHTML = ""; + var s = createElement("div", "sftp-state"); + s.innerHTML = + '
Loading…
'; + list.appendChild(s); + var ctxBar = this._getCtxBar(sessionId); + if (ctxBar) ctxBar.style.display = "none"; } _renderError(sessionId, msg) { - var list = this._getFileList(sessionId); - if (!list) return; - list.innerHTML = ''; - var s = createElement('div', 'sftp-state'); - s.innerHTML = - '
⚠
' + - '
' + escapeHtml(msg) + '
' + - ''; - var self = this; - var retryBtn = s.querySelector('.sftp-state-retry'); - if (retryBtn) { - retryBtn.addEventListener('click', function() { self._refresh(sessionId); }); - } - list.appendChild(s); + var list = this._getFileList(sessionId); + if (!list) return; + list.innerHTML = ""; + var s = createElement("div", "sftp-state"); + s.innerHTML = + '
⚠
' + + '
' + + escapeHtml(msg) + + "
" + + ''; + var self = this; + var retryBtn = s.querySelector(".sftp-state-retry"); + if (retryBtn) { + retryBtn.addEventListener("click", function () { + self._refresh(sessionId); + }); + } + list.appendChild(s); } /* ── Selection & context actions ──────────────────────────────── */ _selectFile(sessionId, entry, rowEl) { - var state = this.panels.get(sessionId); - if (!state) return; + var state = this.panels.get(sessionId); + if (!state) return; - // Deselect previous - var prev = state.el.querySelector('.sftp-file-row.is-selected'); - if (prev) prev.classList.remove('is-selected'); + // Deselect previous + var prev = state.el.querySelector(".sftp-file-row.is-selected"); + if (prev) prev.classList.remove("is-selected"); - // If clicking the same entry, deselect - if (state.selected === entry.path) { - state.selected = null; - var ctxBar = this._getCtxBar(sessionId); - if (ctxBar) ctxBar.style.display = 'none'; - return; - } + // If clicking the same entry, deselect + if (state.selected === entry.path) { + state.selected = null; + var ctxBar = this._getCtxBar(sessionId); + if (ctxBar) ctxBar.style.display = "none"; + return; + } - state.selected = entry.path; - rowEl.classList.add('is-selected'); - this._showContextBar(sessionId, entry); + state.selected = entry.path; + rowEl.classList.add("is-selected"); + this._showContextBar(sessionId, entry); } _showContextBar(sessionId, entry) { - var ctxBar = this._getCtxBar(sessionId); - if (!ctxBar) return; + var ctxBar = this._getCtxBar(sessionId); + if (!ctxBar) return; - ctxBar.innerHTML = ''; - ctxBar.style.display = 'flex'; + ctxBar.innerHTML = ""; + ctxBar.style.display = "flex"; - var label = createElement('span', 'sftp-ctx-label', { - textContent: entry.name, - title: entry.path, + var label = createElement("span", "sftp-ctx-label", { + textContent: entry.name, + title: entry.path, + }); + ctxBar.appendChild(label); + + var self = this; + + if (!entry.isDir) { + var dlBtn = createElement("button", "sftp-ctx-btn is-accent", { + textContent: "↓ Download", }); - ctxBar.appendChild(label); + dlBtn.addEventListener("click", function () { + self._download(sessionId, entry.path); + }); + ctxBar.appendChild(dlBtn); + } - var self = this; + var renameBtn = createElement("button", "sftp-ctx-btn", { + textContent: "✎ Rename", + }); + renameBtn.addEventListener("click", function () { + self._promptRename(sessionId, entry); + }); + ctxBar.appendChild(renameBtn); - if (!entry.isDir) { - var dlBtn = createElement('button', 'sftp-ctx-btn is-accent', { textContent: '↓ Download' }); - dlBtn.addEventListener('click', function() { self._download(sessionId, entry.path); }); - ctxBar.appendChild(dlBtn); - } - - var renameBtn = createElement('button', 'sftp-ctx-btn', { textContent: '✎ Rename' }); - renameBtn.addEventListener('click', function() { self._promptRename(sessionId, entry); }); - ctxBar.appendChild(renameBtn); - - var delBtn = createElement('button', 'sftp-ctx-btn is-danger', { textContent: '✕ Delete' }); - delBtn.addEventListener('click', function() { self._confirmDelete(sessionId, entry); }); - ctxBar.appendChild(delBtn); + var delBtn = createElement("button", "sftp-ctx-btn is-danger", { + textContent: "✕ Delete", + }); + delBtn.addEventListener("click", function () { + self._confirmDelete(sessionId, entry); + }); + ctxBar.appendChild(delBtn); } /* ── File operations ──────────────────────────────────────────── */ async _download(sessionId, filePath) { - try { - await window.go.api.Service.SFTPDownload(sessionId, filePath); - this.app.notifications.success('Download started'); - } catch (err) { - this.app.notifications.error('Download failed: ' + (err.message || err)); - } + try { + await window.go.api.Service.SFTPDownload(sessionId, filePath); + this.app.notifications.success("Download started"); + } catch (err) { + this.app.notifications.error( + "Download failed: " + (err.message || err), + ); + } } async _upload(sessionId) { - var state = this.panels.get(sessionId); - if (!state) return; - try { - await window.go.api.Service.SFTPUpload(sessionId, state.cwd); - this.app.notifications.success('Upload complete'); - await this._refresh(sessionId); - } catch (err) { - // User may have cancelled the dialog — only show error if meaningful - var msg = err.message || String(err); - if (msg && msg.toLowerCase().indexOf('cancel') === -1) { - this.app.notifications.error('Upload failed: ' + msg); - } + var state = this.panels.get(sessionId); + if (!state) return; + try { + await window.go.api.Service.SFTPUpload(sessionId, state.cwd); + this.app.notifications.success("Upload complete"); + await this._refresh(sessionId); + } catch (err) { + // User may have cancelled the dialog — only show error if meaningful + var msg = err.message || String(err); + if (msg && msg.toLowerCase().indexOf("cancel") === -1) { + this.app.notifications.error("Upload failed: " + msg); } + } } async _mkdir(sessionId, name) { - var state = this.panels.get(sessionId); - if (!state) return; - var fullPath = pathJoin(state.cwd, name); - try { - await window.go.api.Service.SFTPMkdir(sessionId, fullPath); - this.app.notifications.success('Directory created'); - await this._refresh(sessionId); - } catch (err) { - this.app.notifications.error('mkdir failed: ' + (err.message || err)); - } + var state = this.panels.get(sessionId); + if (!state) return; + var fullPath = pathJoin(state.cwd, name); + try { + await window.go.api.Service.SFTPMkdir(sessionId, fullPath); + this.app.notifications.success("Directory created"); + await this._refresh(sessionId); + } catch (err) { + this.app.notifications.error("mkdir failed: " + (err.message || err)); + } } async _rename(sessionId, oldPath, newName) { - var state = this.panels.get(sessionId); - if (!state) return; - var newPath = pathJoin(parentPath(oldPath), newName); - try { - await window.go.api.Service.SFTPRename(sessionId, oldPath, newPath); - this.app.notifications.success('Renamed'); - await this._refresh(sessionId); - } catch (err) { - this.app.notifications.error('Rename failed: ' + (err.message || err)); - } + var state = this.panels.get(sessionId); + if (!state) return; + var newPath = pathJoin(parentPath(oldPath), newName); + try { + await window.go.api.Service.SFTPRename(sessionId, oldPath, newPath); + this.app.notifications.success("Renamed"); + await this._refresh(sessionId); + } catch (err) { + this.app.notifications.error("Rename failed: " + (err.message || err)); + } } async _delete(sessionId, entry) { - try { - await window.go.api.Service.SFTPRemove(sessionId, entry.path); - this.app.notifications.success((entry.isDir ? 'Directory' : 'File') + ' deleted'); - await this._refresh(sessionId); - } catch (err) { - this.app.notifications.error('Delete failed: ' + (err.message || err)); - } + try { + await window.go.api.Service.SFTPRemove(sessionId, entry.path); + this.app.notifications.success( + (entry.isDir ? "Directory" : "File") + " deleted", + ); + await this._refresh(sessionId); + } catch (err) { + this.app.notifications.error("Delete failed: " + (err.message || err)); + } } /* ── Modal dialogs (no prompt/confirm/alert) ──────────────────── */ _promptMkdir(sessionId) { - this._showInputModal({ - title: 'New Folder', - placeholder: 'Folder name', - confirmLabel: 'Create', - onConfirm: function(value) { - if (value.trim()) this._mkdir(sessionId, value.trim()); - }.bind(this), - }); + this._showInputModal({ + title: "New Folder", + placeholder: "Folder name", + confirmLabel: "Create", + onConfirm: function (value) { + if (value.trim()) this._mkdir(sessionId, value.trim()); + }.bind(this), + }); } _promptRename(sessionId, entry) { - this._showInputModal({ - title: 'Rename', - placeholder: 'New name', - initialValue: entry.name, - confirmLabel: 'Rename', - onConfirm: function(value) { - var v = value.trim(); - if (v && v !== entry.name) this._rename(sessionId, entry.path, v); - }.bind(this), - }); + this._showInputModal({ + title: "Rename", + placeholder: "New name", + initialValue: entry.name, + confirmLabel: "Rename", + onConfirm: function (value) { + var v = value.trim(); + if (v && v !== entry.name) this._rename(sessionId, entry.path, v); + }.bind(this), + }); } _confirmDelete(sessionId, entry) { - this._showConfirmModal({ - title: 'Delete ' + (entry.isDir ? 'Folder' : 'File'), - message: 'Permanently delete ' + escapeHtml(entry.name) + '?', - confirmLabel: 'Delete', - isDanger: true, - onConfirm: function() { - this._delete(sessionId, entry); - }.bind(this), - }); + this._showConfirmModal({ + title: "Delete " + (entry.isDir ? "Folder" : "File"), + message: + "Permanently delete " + escapeHtml(entry.name) + "?", + confirmLabel: "Delete", + isDanger: true, + onConfirm: function () { + this._delete(sessionId, entry); + }.bind(this), + }); } /** * Displays a modal with a text input field. */ _showInputModal(opts) { - var backdrop = createElement('div', 'modal-backdrop'); - var modal = createElement('div', 'modal modal-sm'); - modal.innerHTML = - '' + - '' + - ''; + var backdrop = createElement("div", "modal-backdrop"); + var modal = createElement("div", "modal modal-sm"); + modal.innerHTML = + '" + + '" + + '"; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); - var input = modal.querySelector('.sftp-inline-input'); - requestAnimationFrame(function() { - backdrop.classList.add('is-visible'); - modal.classList.add('is-visible'); - input.focus(); - input.select(); - }); + var input = modal.querySelector(".sftp-inline-input"); + requestAnimationFrame(function () { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); + input.focus(); + input.select(); + }); - var closed = false; - function close(confirmed) { - if (closed) return; - closed = true; - backdrop.classList.remove('is-visible'); - modal.classList.remove('is-visible'); - setTimeout(function() { backdrop.remove(); }, 200); - if (confirmed && opts.onConfirm) { - opts.onConfirm(input.value); - } + var closed = false; + function close(confirmed) { + if (closed) return; + closed = true; + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(function () { + backdrop.remove(); + }, 200); + if (confirmed && opts.onConfirm) { + opts.onConfirm(input.value); } + } - modal.querySelector('.modal-close').addEventListener('click', function() { close(false); }); - modal.querySelector('.sftp-modal-cancel').addEventListener('click', function() { close(false); }); - modal.querySelector('.sftp-modal-confirm').addEventListener('click', function() { close(true); }); - backdrop.addEventListener('click', function(e) { if (e.target === backdrop) close(false); }); - input.addEventListener('keydown', function(e) { - if (e.key === 'Enter') close(true); - if (e.key === 'Escape') close(false); + modal + .querySelector(".modal-close") + .addEventListener("click", function () { + close(false); }); + modal + .querySelector(".sftp-modal-cancel") + .addEventListener("click", function () { + close(false); + }); + modal + .querySelector(".sftp-modal-confirm") + .addEventListener("click", function () { + close(true); + }); + backdrop.addEventListener("click", function (e) { + if (e.target === backdrop) close(false); + }); + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") close(true); + if (e.key === "Escape") close(false); + }); } /** * Displays a confirmation modal (no native confirm). */ _showConfirmModal(opts) { - var backdrop = createElement('div', 'modal-backdrop'); - var modal = createElement('div', 'modal modal-sm confirm-dialog'); - modal.innerHTML = - '' + - '' + - ''; + var backdrop = createElement("div", "modal-backdrop"); + var modal = createElement("div", "modal modal-sm confirm-dialog"); + modal.innerHTML = + '" + + '" + + '"; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); - requestAnimationFrame(function() { - backdrop.classList.add('is-visible'); - modal.classList.add('is-visible'); - }); + requestAnimationFrame(function () { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); + }); - var closed = false; - function close(confirmed) { - if (closed) return; - closed = true; - backdrop.classList.remove('is-visible'); - modal.classList.remove('is-visible'); - setTimeout(function() { backdrop.remove(); }, 200); - if (confirmed && opts.onConfirm) { - opts.onConfirm(); - } + var closed = false; + function close(confirmed) { + if (closed) return; + closed = true; + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(function () { + backdrop.remove(); + }, 200); + if (confirmed && opts.onConfirm) { + opts.onConfirm(); } + } - modal.querySelector('.modal-close').addEventListener('click', function() { close(false); }); - modal.querySelector('.sftp-modal-cancel').addEventListener('click', function() { close(false); }); - modal.querySelector('.sftp-modal-confirm').addEventListener('click', function() { close(true); }); - backdrop.addEventListener('click', function(e) { if (e.target === backdrop) close(false); }); + modal + .querySelector(".modal-close") + .addEventListener("click", function () { + close(false); + }); + modal + .querySelector(".sftp-modal-cancel") + .addEventListener("click", function () { + close(false); + }); + modal + .querySelector(".sftp-modal-confirm") + .addEventListener("click", function () { + close(true); + }); + backdrop.addEventListener("click", function (e) { + if (e.target === backdrop) close(false); + }); } /* ── Resize handle ────────────────────────────────────────────── */ _setupResize(sessionId, state) { - var handle = state.handle; - var panel = state.el; - var self = this; - var startX, startWidth; + var handle = state.handle; + var panel = state.el; + var self = this; + var startX, startWidth; - function onMouseMove(e) { - var delta = startX - e.clientX; - var newWidth = Math.max(180, Math.min(startWidth + delta, window.innerWidth * 0.5)); - panel.style.width = newWidth + 'px'; - } + function onMouseMove(e) { + var delta = startX - e.clientX; + var newWidth = Math.max( + 180, + Math.min(startWidth + delta, window.innerWidth * 0.5), + ); + panel.style.width = newWidth + "px"; + } - function onMouseUp() { - handle.classList.remove('is-dragging'); - document.body.style.userSelect = ''; - document.body.style.cursor = ''; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - self._refitTerminal(sessionId); - } + function onMouseUp() { + handle.classList.remove("is-dragging"); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + self._refitTerminal(sessionId); + } - handle.addEventListener('mousedown', function(e) { - e.preventDefault(); - startX = e.clientX; - startWidth = panel.offsetWidth; - handle.classList.add('is-dragging'); - document.body.style.userSelect = 'none'; - document.body.style.cursor = 'col-resize'; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }); + handle.addEventListener("mousedown", function (e) { + e.preventDefault(); + startX = e.clientX; + startWidth = panel.offsetWidth; + handle.classList.add("is-dragging"); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); } /* ── Terminal refit ────────────────────────────────────────────── */ _refitTerminal(sessionId) { - if (this.app.terminals && typeof this.app.terminals.fitSession === 'function') { - // Small delay lets the layout settle before measuring - setTimeout(function() { - this.app.terminals.fitSession(sessionId); - }.bind(this), 50); - } + if ( + this.app.terminals && + typeof this.app.terminals.fitSession === "function" + ) { + // Small delay lets the layout settle before measuring + setTimeout( + function () { + this.app.terminals.fitSession(sessionId); + }.bind(this), + 50, + ); + } } -} - -/* ── Export ────────────────────────────────────────────────────────── */ -window.NexusTerminal = window.NexusTerminal || {}; -window.NexusTerminal.SFTPBrowser = SFTPBrowser; + } + /* ── Export ────────────────────────────────────────────────────────── */ + window.NexusTerminal = window.NexusTerminal || {}; + window.NexusTerminal.SFTPBrowser = SFTPBrowser; })(); diff --git a/frontend/src/js/sidebar.js b/frontend/src/js/sidebar.js index e580ff9..199a015 100644 --- a/frontend/src/js/sidebar.js +++ b/frontend/src/js/sidebar.js @@ -253,11 +253,22 @@ const editBtn = createElement("button", "sidebar-item-edit", { textContent: "✎", title: "Edit profile", + type: "button", + "aria-label": "Edit profile", }); - editBtn.addEventListener("click", (e) => { + let editTriggered = false; + const triggerEdit = (e) => { + e.preventDefault(); e.stopPropagation(); + if (editTriggered) return; + editTriggered = true; + requestAnimationFrame(() => { + editTriggered = false; + }); this.app.profileEditor.openEdit(profile.id); - }); + }; + editBtn.addEventListener("mousedown", triggerEdit); + editBtn.addEventListener("click", triggerEdit); item.appendChild(editBtn); // Favourite star @@ -268,8 +279,11 @@ item.appendChild(star); } - // Prevent mousedown text selection in WKWebView - item.addEventListener("mousedown", (e) => e.preventDefault()); + // Prevent mousedown text selection in WKWebView, but allow real controls. + item.addEventListener("mousedown", (e) => { + if (e.target.closest("button, input, select, textarea")) return; + e.preventDefault(); + }); // Click → connect / multi-select item.addEventListener("click", (e) => { diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js index 90fe844..cf27a3a 100644 --- a/frontend/src/js/tabs.js +++ b/frontend/src/js/tabs.js @@ -120,6 +120,14 @@ class TabManager { } } + /** + * Reconnects a session by ID. + * @param {string} sessionId + */ + reconnectSession(sessionId) { + return this._reconnect(sessionId); + } + /** * Disconnects and closes a tab by session ID. * @param {string} sessionId diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index 206cb64..ff3174a 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -104,7 +104,10 @@ // Drag-and-drop file handling container.addEventListener("dragover", (e) => { - if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files")) { + if ( + e.dataTransfer && + Array.from(e.dataTransfer.types || []).includes("Files") + ) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } @@ -230,9 +233,7 @@ webLinksAddon, observer, decoder: - typeof TextDecoder !== "undefined" - ? new TextDecoder("utf-8") - : null, + typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null, }); return term; @@ -327,6 +328,28 @@ } } + /** + * Shows or hides the disconnected overlay for a session panel. + * @param {string} sessionId + * @param {boolean} isDisconnected + * @param {string} [error] + */ + setDisconnectedOverlay(sessionId, isDisconnected, error) { + const panel = document.getElementById(`panel-${sessionId}`); + if (!panel) return; + + const overlay = this._ensureDisconnectOverlay(panel, sessionId); + if (!overlay) return; + + const message = overlay.querySelector(".terminal-disconnect-message"); + if (message) { + message.textContent = error ? error : "Connection lost."; + } + + panel.classList.toggle("is-disconnected", isDisconnected); + overlay.classList.toggle("is-visible", isDisconnected); + } + /** * Searches within a terminal using the search addon. * @param {string} sessionId @@ -416,6 +439,44 @@ // ── Private helpers ────────────────────────────────────────────── + /** + * Ensures the disconnect overlay exists for a session panel. + * @param {HTMLElement} panel + * @param {string} sessionId + * @returns {HTMLElement|null} + * @private + */ + _ensureDisconnectOverlay(panel, sessionId) { + let overlay = panel.querySelector(".terminal-disconnect-overlay"); + if (overlay) return overlay; + + const { createElement } = window.NexusTerminal; + if (!createElement) return null; + + overlay = createElement("div", "terminal-disconnect-overlay"); + const content = createElement("div", "terminal-disconnect-content"); + const title = createElement("div", "terminal-disconnect-title", { + textContent: "DISCONNECTED", + }); + const message = createElement("div", "terminal-disconnect-message", { + textContent: "Connection lost.", + }); + const button = createElement("button", "btn btn-sm btn-primary", { + textContent: "Reconnect", + }); + button.addEventListener("click", () => { + this.app.tabs.reconnectSession(sessionId); + }); + + content.appendChild(title); + content.appendChild(message); + content.appendChild(button); + overlay.appendChild(content); + panel.appendChild(overlay); + + return overlay; + } + /** * Shows a right-click context menu for a terminal. * @param {string} sessionId @@ -540,7 +601,9 @@ } if (choice === "upload") { if (!canUpload) { - this.app.notifications.error("Upload is only available for SSH sessions."); + this.app.notifications.error( + "Upload is only available for SSH sessions.", + ); return; } this._uploadDroppedFiles(sessionId, files); @@ -555,8 +618,12 @@ const modal = createElement("div", "modal modal-sm"); const count = files.length; const first = files[0]; - const sizeLabel = first && typeof first.size === "number" ? formatBytes(first.size) : ""; - const title = count === 1 ? (first ? first.name : "File") : count + " files"; + const sizeLabel = + first && typeof first.size === "number" + ? formatBytes(first.size) + : ""; + const title = + count === 1 ? (first ? first.name : "File") : count + " files"; modal.innerHTML = `