feat: enhance sidebar edit functionality and prevent text selection

- Added aria-label and type attributes to the edit button in the sidebar.
- Implemented a mechanism to prevent multiple edit triggers using requestAnimationFrame.
- Updated mousedown event handling to allow real controls while preventing text selection in WKWebView.

feat: add reconnectSession method to TabManager

- Introduced reconnectSession method to facilitate session reconnections by ID.

feat: implement disconnected overlay in terminal

- Added setDisconnectedOverlay method to show/hide a disconnected overlay for terminal sessions.
- Created _ensureDisconnectOverlay method to manage the overlay's existence and content.

feat: extend Service API with file-related functions

- Added FileExists and FileIsPrivateKey functions to check file existence and identify private keys.
- Introduced ListSSHKeys function to retrieve SSH private key paths from the user's .ssh directory.
- Implemented SFTPStat and SFTPUploadData functions for enhanced SFTP operations.

refactor: clean up code formatting and improve readability

- Adjusted code formatting for better readability across various files, including consistent use of line breaks and indentation.
This commit is contained in:
Andy Dixon
2026-04-22 14:56:52 +01:00
parent 2ce145ba0f
commit 8fa9fb5be5
20 changed files with 2066 additions and 1300 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ dev: deps
# Production build # Production build
build: deps build: deps
$(WAILS) build $(WAILS) build -ldflags "-s -w"
# Run tests # Run tests
test: test:
+17 -17
View File
@@ -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. All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux.
| Shortcut | Action | | macOS Shortcut | Windows/Linux Shortcut | Action |
| ----------------- | --------------------------------- | | ------------- | ----------------------- | --------------------------------- |
| Cmd + K | Open command palette | | Cmd + K | Ctrl + K | Open command palette |
| Cmd + N | New connection | | Cmd + N | Ctrl + N | New connection |
| Cmd + W | Close current tab | | Cmd + W | Ctrl + W | Close current tab |
| Cmd + Tab | Next tab | | Cmd + Tab | Ctrl + Tab | Next tab |
| Cmd + Shift + Tab | Previous tab | | Cmd + Shift + Tab | Ctrl + Shift + Tab | Previous tab |
| Cmd + 1–9 | Switch to tab by number | | Cmd + 1–9 | Ctrl + 1–9 | Switch to tab by number |
| Cmd + , | Open settings | | Cmd + , | Ctrl + , | Open settings |
| Cmd + F | Find in terminal | | Cmd + F | Ctrl + F | Find in terminal |
| Cmd + Shift + D | Split pane vertically | | Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically |
| Cmd + Shift + E | Split pane horizontally | | Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally |
| Cmd + Shift + R | Toggle session recording | | Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording |
| Cmd + Shift + B | Toggle broadcast input mode | | Cmd + Shift + B | Ctrl + Shift + B | Toggle broadcast input mode |
| Cmd + Shift + S | Open snippet library | | Cmd + Shift + S | Ctrl + Shift + S | Open snippet library |
| Cmd + Shift + Q | Quick connect | | Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect |
| F11 | Toggle fullscreen (Windows/Linux) | | F11 | F11 | Toggle fullscreen |
+2 -2
View File
@@ -94,9 +94,9 @@ build_target() {
log "Building ${platform}…" log "Building ${platform}…"
if [ -n "$cc" ]; then 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 else
CGO_ENABLED=1 wails build -platform "$platform" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "-s -w"
fi fi
} }
+1 -1
View File
@@ -61,7 +61,7 @@ function Build {
$env:CGO_ENABLED = '1' $env:CGO_ENABLED = '1'
Write-Info 'Building production binary (CGO_ENABLED=1)...' Write-Info 'Building production binary (CGO_ENABLED=1)...'
& $wailsPath build & $wailsPath build -ldflags "-s -w"
Write-Info 'Build complete: build\bin\' Write-Info 'Build complete: build\bin\'
} }
+1 -1
View File
@@ -62,7 +62,7 @@ build() {
check_deps check_deps
install_deps install_deps
log "Building production binary…" log "Building production binary…"
wails build wails build -ldflags "-s -w"
log "Build complete: build/bin/" log "Build complete: build/bin/"
} }
+14 -14
View File
@@ -14,9 +14,9 @@ One-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** for quick connect.
## Daily workflow ## Daily workflow
- **Tabs and splits**: Drag tabs to reorder, and split the workspace to view multiple sessions at once. - **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. - **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 ## File transfers on SSH
@@ -42,18 +42,18 @@ The upload picker lists folders only and marks permissions:
## Common shortcuts ## Common shortcuts
| Shortcut | Action | | macOS Shortcut | Windows/Linux Shortcut | Action |
| ----------------- | --------------------------- | | ------------- | ----------------------- | ------------------------ |
| Cmd + K | Command palette | | Cmd + K | Ctrl + K | Command palette |
| Cmd + N | New connection | | Cmd + N | Ctrl + N | New connection |
| Cmd + W | Close tab | | Cmd + W | Ctrl + W | Close tab |
| Cmd + F | Find in terminal | | Cmd + F | Ctrl + F | Find in terminal |
| Cmd + Shift + S | Snippet library | | Cmd + Shift + S | Ctrl + Shift + S | Snippet library |
| Cmd + Shift + B | Broadcast input | | Cmd + Shift + B | Ctrl + Shift + B | Broadcast input |
| Cmd + Shift + Q | Quick connect | | Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect |
| Cmd + Shift + R | Toggle session recording | | Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording |
| Cmd + Shift + D | Split pane vertically | | Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically |
| Cmd + Shift + E | Split pane horizontally | | Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally |
## Need more detail? ## Need more detail?
+5 -5
View File
@@ -122,7 +122,7 @@ Screenshot: Terminal context menu and search bar.
### Search ### 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 ### 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. Screenshot: Snippet library with search and folder grouping.
### Broadcast input ### 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 ## 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. Screenshot: Recordings list with playback controls.
@@ -216,7 +216,7 @@ Screenshot: TOTP configuration in the profile editor.
## 12. Settings ## 12. Settings
Open settings with **Cmd+,** and configure: Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux) and configure:
- Terminal themes and fonts - Terminal themes and fonts
- Default behaviours - Default behaviours
+22
View File
@@ -459,6 +459,13 @@
color: var(--danger); 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 Toggle wrapper — label + toggle switch in a row
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
@@ -684,6 +691,21 @@
flex: 1; 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-toggle,
.totp-test, .totp-test,
.totp-save, .totp-save,
File diff suppressed because it is too large Load Diff
+235 -227
View File
@@ -7,407 +7,415 @@
Panel wrapper — overlays the terminal from the right side Panel wrapper — overlays the terminal from the right side
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.sftp-panel { .sftp-panel {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
width: 300px; width: 300px;
min-width: 220px; min-width: 220px;
max-width: 50vw; max-width: 50vw;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-surface); background: var(--bg-surface);
border-left: 1px solid var(--border-subtle); border-left: 1px solid var(--border-subtle);
overflow: hidden; overflow: hidden;
z-index: var(--z-dropdown, 50); z-index: var(--z-dropdown, 50);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25); box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25);
} }
/* Resize handle — positioned at the left edge of the SFTP panel */ /* Resize handle — positioned at the left edge of the SFTP panel */
.sftp-resize-handle { .sftp-resize-handle {
position: absolute; position: absolute;
top: 0; top: 0;
left: -3px; left: -3px;
bottom: 0; bottom: 0;
width: 6px; width: 6px;
cursor: col-resize; cursor: col-resize;
background: transparent; background: transparent;
z-index: var(--z-dropdown, 50); z-index: var(--z-dropdown, 50);
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
.sftp-resize-handle:hover, .sftp-resize-handle:hover,
.sftp-resize-handle.is-dragging { .sftp-resize-handle.is-dragging {
background: var(--accent); background: var(--accent);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Toolbar (top bar with path breadcrumb + action buttons) Toolbar (top bar with path breadcrumb + action buttons)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.sftp-toolbar { .sftp-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
background: var(--bg-elevated); background: var(--bg-elevated);
flex-shrink: 0; flex-shrink: 0;
min-height: 34px; min-height: 34px;
} }
.sftp-toolbar-actions { .sftp-toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
flex-shrink: 0; flex-shrink: 0;
} }
.sftp-toolbar-btn { .sftp-toolbar-btn {
width: 26px; width: 26px;
height: 26px; height: 26px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: none; border: none;
background: none; background: none;
color: var(--text-tertiary); color: var(--text-tertiary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: var(--text-sm); font-size: var(--text-sm);
transition: background var(--transition-fast), color var(--transition-fast); transition:
flex-shrink: 0; background var(--transition-fast),
padding: 0; color var(--transition-fast);
flex-shrink: 0;
padding: 0;
} }
.sftp-toolbar-btn:hover { .sftp-toolbar-btn:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
.sftp-toolbar-btn.is-accent:hover { .sftp-toolbar-btn.is-accent:hover {
color: var(--accent); color: var(--accent);
} }
.sftp-toolbar-btn.is-close:hover { .sftp-toolbar-btn.is-close:hover {
color: var(--error); color: var(--error);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Breadcrumb path Breadcrumb path
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.sftp-breadcrumb { .sftp-breadcrumb {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-tertiary); color: var(--text-tertiary);
gap: 1px; gap: 1px;
} }
.sftp-breadcrumb-segment { .sftp-breadcrumb-segment {
background: none; background: none;
border: none; border: none;
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--text-xs); font-size: var(--text-xs);
cursor: pointer; cursor: pointer;
padding: 2px 4px; padding: 2px 4px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
white-space: nowrap; white-space: nowrap;
transition: background var(--transition-fast), color var(--transition-fast); transition:
max-width: 100px; background var(--transition-fast),
overflow: hidden; color var(--transition-fast);
text-overflow: ellipsis; max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
} }
.sftp-breadcrumb-segment:hover { .sftp-breadcrumb-segment:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
.sftp-breadcrumb-segment:last-child { .sftp-breadcrumb-segment:last-child {
color: var(--text-primary); color: var(--text-primary);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
} }
.sftp-breadcrumb-sep { .sftp-breadcrumb-sep {
color: var(--text-disabled); color: var(--text-disabled);
font-size: 10px; font-size: 10px;
flex-shrink: 0; flex-shrink: 0;
user-select: none; user-select: none;
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
File list File list
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.sftp-file-list { .sftp-file-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
/* Scrollbar styling */ /* Scrollbar styling */
.sftp-file-list::-webkit-scrollbar { .sftp-file-list::-webkit-scrollbar {
width: 6px; width: 6px;
} }
.sftp-file-list::-webkit-scrollbar-track { .sftp-file-list::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.sftp-file-list::-webkit-scrollbar-thumb { .sftp-file-list::-webkit-scrollbar-thumb {
background: var(--border-strong, var(--bg-active)); background: var(--border-strong, var(--bg-active));
border-radius: 3px; border-radius: 3px;
} }
.sftp-file-list::-webkit-scrollbar-thumb:hover { .sftp-file-list::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary); background: var(--text-tertiary);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
File row File row
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.sftp-file-row { .sftp-file-row {
display: grid; display: grid;
grid-template-columns: 20px 1fr auto; grid-template-columns: 20px 1fr auto;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
cursor: pointer; cursor: pointer;
transition: background var(--transition-fast); transition: background var(--transition-fast);
user-select: none; user-select: none;
min-height: 30px; min-height: 30px;
} }
.sftp-file-row:hover { .sftp-file-row:hover {
background: var(--bg-hover); background: var(--bg-hover);
} }
.sftp-file-row.is-selected { .sftp-file-row.is-selected {
background: var(--accent-muted); background: var(--accent-muted);
} }
.sftp-file-row.is-selected:hover { .sftp-file-row.is-selected:hover {
background: var(--accent-strong); background: var(--accent-strong);
} }
.sftp-file-row.is-readonly .sftp-file-meta { .sftp-file-row.is-readonly .sftp-file-meta {
color: var(--warning); color: var(--warning);
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
} }
.sftp-file-row.is-unreadable { .sftp-file-row.is-unreadable {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.sftp-file-row.is-unreadable .sftp-file-name { .sftp-file-row.is-unreadable .sftp-file-name {
text-decoration: line-through; text-decoration: line-through;
} }
.sftp-file-icon { .sftp-file-icon {
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
line-height: 1; line-height: 1;
flex-shrink: 0; flex-shrink: 0;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.sftp-file-icon.is-dir { .sftp-file-icon.is-dir {
color: var(--accent); color: var(--accent);
} }
.sftp-file-name { .sftp-file-name {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-primary); color: var(--text-primary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0; min-width: 0;
} }
.sftp-file-meta { .sftp-file-meta {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-tertiary); color: var(--text-tertiary);
white-space: nowrap; white-space: nowrap;
text-align: right; text-align: right;
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Parent directory row (..) Parent directory row (..)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.sftp-file-row.is-parent .sftp-file-name { .sftp-file-row.is-parent .sftp-file-name {
color: var(--text-secondary); color: var(--text-secondary);
font-style: italic; font-style: italic;
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Context actions bar (shown below file list when a file is selected) Context actions bar (shown below file list when a file is selected)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.sftp-context-bar { .sftp-context-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
background: var(--bg-elevated); background: var(--bg-elevated);
flex-shrink: 0; flex-shrink: 0;
min-height: 32px; min-height: 32px;
} }
.sftp-context-bar .sftp-ctx-label { .sftp-context-bar .sftp-ctx-label {
flex: 1; flex: 1;
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-secondary); color: var(--text-secondary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0; min-width: 0;
} }
.sftp-context-bar .sftp-ctx-btn { .sftp-context-bar .sftp-ctx-btn {
height: 24px; height: 24px;
padding: 0 var(--space-2); padding: 0 var(--space-2);
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
transition: background var(--transition-fast), color var(--transition-fast); transition:
white-space: nowrap; background var(--transition-fast),
color var(--transition-fast);
white-space: nowrap;
} }
.sftp-context-bar .sftp-ctx-btn:hover { .sftp-context-bar .sftp-ctx-btn:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
} }
.sftp-context-bar .sftp-ctx-btn.is-danger:hover { .sftp-context-bar .sftp-ctx-btn.is-danger:hover {
background: rgba(239, 83, 80, 0.12); background: rgba(239, 83, 80, 0.12);
color: var(--error); color: var(--error);
} }
.sftp-context-bar .sftp-ctx-btn.is-accent:hover { .sftp-context-bar .sftp-ctx-btn.is-accent:hover {
color: var(--accent); color: var(--accent);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
States: loading, empty, error States: loading, empty, error
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.sftp-state { .sftp-state {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--space-6) var(--space-4); padding: var(--space-6) var(--space-4);
text-align: center; text-align: center;
gap: var(--space-2); gap: var(--space-2);
} }
.sftp-state-icon { .sftp-state-icon {
font-size: 28px; font-size: 28px;
color: var(--text-disabled); color: var(--text-disabled);
} }
.sftp-state-msg { .sftp-state-msg {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-tertiary); color: var(--text-tertiary);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
max-width: 200px; max-width: 200px;
} }
.sftp-state-retry { .sftp-state-retry {
margin-top: var(--space-1); margin-top: var(--space-1);
} }
/* Loading spinner (pure CSS) */ /* Loading spinner (pure CSS) */
.sftp-spinner { .sftp-spinner {
width: 20px; width: 20px;
height: 20px; height: 20px;
border: 2px solid var(--border-subtle); border: 2px solid var(--border-subtle);
border-top-color: var(--accent); border-top-color: var(--accent);
border-radius: 50%; border-radius: 50%;
animation: sftp-spin 0.6s linear infinite; animation: sftp-spin 0.6s linear infinite;
} }
@keyframes sftp-spin { @keyframes sftp-spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Inline input (for rename / mkdir) Inline input (for rename / mkdir)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.sftp-inline-input { .sftp-inline-input {
width: 100%; width: 100%;
height: 26px; height: 26px;
padding: 0 var(--space-2); padding: 0 var(--space-2);
font-size: var(--text-sm); font-size: var(--text-sm);
font-family: inherit; font-family: inherit;
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-input); background: var(--bg-input);
border: 1px solid var(--accent); border: 1px solid var(--accent);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
outline: none; outline: none;
box-shadow: 0 0 0 2px var(--accent-muted); box-shadow: 0 0 0 2px var(--accent-muted);
} }
/* Picker-specific input in the toolbar */ /* Picker-specific input in the toolbar */
.sftp-picker-filter { .sftp-picker-filter {
width: 160px; width: 160px;
height: 26px; height: 26px;
padding: 0 var(--space-2); padding: 0 var(--space-2);
font-size: var(--text-xs); font-size: var(--text-xs);
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-input); background: var(--bg-input);
color: var(--text-primary); color: var(--text-primary);
} }
.sftp-picker-filter:focus { .sftp-picker-filter:focus {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted); box-shadow: 0 0 0 2px var(--accent-muted);
outline: none; outline: none;
} }
.sftp-picker-status { .sftp-picker-status {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-tertiary); color: var(--text-tertiary);
margin-right: var(--space-2); margin-right: var(--space-2);
} }
.sftp-picker-badge { .sftp-picker-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 18px; height: 18px;
padding: 0 var(--space-2); padding: 0 var(--space-2);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 10px; font-size: 10px;
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
background: var(--bg-surface); background: var(--bg-surface);
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.sftp-picker-badge.is-readonly { .sftp-picker-badge.is-readonly {
border-color: rgba(255, 167, 38, 0.35); border-color: rgba(255, 167, 38, 0.35);
color: var(--warning); color: var(--warning);
background: rgba(255, 167, 38, 0.12); background: rgba(255, 167, 38, 0.12);
} }
.sftp-picker-badge.is-noread { .sftp-picker-badge.is-noread {
border-color: rgba(239, 83, 80, 0.35); border-color: rgba(239, 83, 80, 0.35);
color: var(--error); color: var(--error);
background: rgba(239, 83, 80, 0.12); background: rgba(239, 83, 80, 0.12);
} }
.sftp-picker-legend { .sftp-picker-legend {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: var(--space-1);
font-size: 10px; font-size: 10px;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
+54
View File
@@ -65,6 +65,60 @@
padding: 0; 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 */ /* xterm.js overrides for consistent look */
.terminal-container .xterm, .terminal-container .xterm,
.session-panel .xterm { .session-panel .xterm {
+8
View File
@@ -168,6 +168,14 @@
); );
this.updateStatusBar(); this.updateStatusBar();
const showDisconnectedOverlay =
payload.state === "disconnected" || payload.state === "error";
this.terminals.setDisconnectedOverlay(
payload.sessionID,
showDisconnectedOverlay,
payload.error,
);
if (payload.state === "error" && payload.error) { if (payload.state === "error" && payload.error) {
this._handleSessionError(payload.sessionID, payload.error); this._handleSessionError(payload.sessionID, payload.error);
} }
+215 -10
View File
@@ -82,6 +82,15 @@
/** @type {boolean} */ /** @type {boolean} */
this._isNew = false; this._isNew = false;
/** @type {string[]} */
this._sshKeyOptions = [];
/** @type {boolean} */
this._sshKeyOptionsLoaded = false;
/** @type {boolean} */
this._sshKeyOptionsLoading = false;
} }
// ── Public API ─────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────
@@ -151,6 +160,7 @@
// ── Scrollable body ───────────────────────────────────── // ── Scrollable body ─────────────────────────────────────
const body = createElement("div", "drawer-body"); const body = createElement("div", "drawer-body");
const sshKeyDatalist = this._buildSSHKeyDatalist();
// General section (common to all protocols) // General section (common to all protocols)
body.appendChild(this._renderGeneralSection(profile)); body.appendChild(this._renderGeneralSection(profile));
@@ -159,6 +169,7 @@
switch (profile.protocol) { switch (profile.protocol) {
case "ssh": case "ssh":
this._renderSSHSections(profile).forEach((s) => body.appendChild(s)); this._renderSSHSections(profile).forEach((s) => body.appendChild(s));
body.appendChild(sshKeyDatalist);
break; break;
case "telnet": case "telnet":
this._renderTelnetSections(profile).forEach((s) => this._renderTelnetSections(profile).forEach((s) =>
@@ -240,6 +251,8 @@
// Focus the first input // Focus the first input
const firstInput = drawer.querySelector("input, select, textarea"); const firstInput = drawer.querySelector("input, select, textarea");
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
this._loadSSHKeyOptions();
} }
/** /**
@@ -377,6 +390,42 @@
return group; 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 */ /** @private */
_renderSSHSections(profile) { _renderSSHSections(profile) {
const ssh = profile.ssh || {}; const ssh = profile.ssh || {};
@@ -413,15 +462,7 @@
// Key & Passphrase // Key & Passphrase
sections.push( sections.push(
this._renderSection("Key & Passphrase", [ this._renderSection("Key & Passphrase", [
this._field( this._renderSSHKeyPathField(ssh.privateKeyPath),
"text",
"ssh.privateKeyPath",
"Private Key Path",
ssh.privateKeyPath,
{
placeholder: "~/.ssh/id_ed25519",
},
),
this._field( this._field(
"select", "select",
"ssh.keySecretMode", "ssh.keySecretMode",
@@ -1522,6 +1563,10 @@
group.appendChild(input); group.appendChild(input);
if (opts.datalistId && input && input.tagName === "INPUT") {
input.setAttribute("list", opts.datalistId);
}
// Help text // Help text
if (opts.help) { if (opts.help) {
group.appendChild( group.appendChild(
@@ -1532,6 +1577,64 @@
return group; 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. * Renders the jump-hosts list editor.
* @param {Array<Object>} jumpHosts * @param {Array<Object>} jumpHosts
@@ -1551,15 +1654,41 @@
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || "")}" placeholder="User" /> <input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || "")}" placeholder="User" />
</div> </div>
<div class="jump-host-fields"> <div class="jump-host-fields">
<input class="form-input form-input-sm" name="jumpHost-${i}-privateKeyPath" value="${escapeHtml(jh.privateKeyPath || "")}" placeholder="Private key path (optional)" style="flex:1" /> <input class="form-input form-input-sm" name="jumpHost-${i}-privateKeyPath" value="${escapeHtml(jh.privateKeyPath || "")}" placeholder="Full key path (optional)" list="ssh-key-path-options" style="flex:1" />
<button class="btn btn-sm btn-outline jump-host-browse" type="button" title="Browse">Browse</button>
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">&times;</button> <button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">&times;</button>
</div> </div>
`; `;
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", () => { row.querySelector(".jump-host-remove").addEventListener("click", () => {
jumpHosts.splice(i, 1); jumpHosts.splice(i, 1);
this.render(this._profile, this._isNew); this.render(this._profile, this._isNew);
}); });
wrapper.appendChild(row); wrapper.appendChild(row);
wrapper.appendChild(error);
wrapper.appendChild(success);
} }
const addBtn = createElement("button", "btn btn-sm btn-outline", { const addBtn = createElement("button", "btn btn-sm btn-outline", {
@@ -1581,6 +1710,82 @@
return wrapper; 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. * Renders the port-forwarding rules list editor.
* @param {Array<Object>} portForwards * @param {Array<Object>} portForwards
+630 -485
View File
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -253,11 +253,22 @@
const editBtn = createElement("button", "sidebar-item-edit", { const editBtn = createElement("button", "sidebar-item-edit", {
textContent: "✎", textContent: "✎",
title: "Edit profile", title: "Edit profile",
type: "button",
"aria-label": "Edit profile",
}); });
editBtn.addEventListener("click", (e) => { let editTriggered = false;
const triggerEdit = (e) => {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (editTriggered) return;
editTriggered = true;
requestAnimationFrame(() => {
editTriggered = false;
});
this.app.profileEditor.openEdit(profile.id); this.app.profileEditor.openEdit(profile.id);
}); };
editBtn.addEventListener("mousedown", triggerEdit);
editBtn.addEventListener("click", triggerEdit);
item.appendChild(editBtn); item.appendChild(editBtn);
// Favourite star // Favourite star
@@ -268,8 +279,11 @@
item.appendChild(star); item.appendChild(star);
} }
// Prevent mousedown text selection in WKWebView // Prevent mousedown text selection in WKWebView, but allow real controls.
item.addEventListener("mousedown", (e) => e.preventDefault()); item.addEventListener("mousedown", (e) => {
if (e.target.closest("button, input, select, textarea")) return;
e.preventDefault();
});
// Click → connect / multi-select // Click → connect / multi-select
item.addEventListener("click", (e) => { item.addEventListener("click", (e) => {
+8
View File
@@ -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. * Disconnects and closes a tab by session ID.
* @param {string} sessionId * @param {string} sessionId
+145 -35
View File
@@ -104,7 +104,10 @@
// Drag-and-drop file handling // Drag-and-drop file handling
container.addEventListener("dragover", (e) => { 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.preventDefault();
e.dataTransfer.dropEffect = "copy"; e.dataTransfer.dropEffect = "copy";
} }
@@ -230,9 +233,7 @@
webLinksAddon, webLinksAddon,
observer, observer,
decoder: decoder:
typeof TextDecoder !== "undefined" typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
? new TextDecoder("utf-8")
: null,
}); });
return term; 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. * Searches within a terminal using the search addon.
* @param {string} sessionId * @param {string} sessionId
@@ -416,6 +439,44 @@
// ── Private helpers ────────────────────────────────────────────── // ── 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. * Shows a right-click context menu for a terminal.
* @param {string} sessionId * @param {string} sessionId
@@ -540,7 +601,9 @@
} }
if (choice === "upload") { if (choice === "upload") {
if (!canUpload) { 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; return;
} }
this._uploadDroppedFiles(sessionId, files); this._uploadDroppedFiles(sessionId, files);
@@ -555,8 +618,12 @@
const modal = createElement("div", "modal modal-sm"); const modal = createElement("div", "modal modal-sm");
const count = files.length; const count = files.length;
const first = files[0]; const first = files[0];
const sizeLabel = first && typeof first.size === "number" ? formatBytes(first.size) : ""; const sizeLabel =
const title = count === 1 ? (first ? first.name : "File") : count + " files"; first && typeof first.size === "number"
? formatBytes(first.size)
: "";
const title =
count === 1 ? (first ? first.name : "File") : count + " files";
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-header"> <div class="modal-header">
@@ -648,7 +715,9 @@
if (!remoteDir) return; if (!remoteDir) return;
if (files.length > 1) { if (files.length > 1) {
this.app.notifications.info("Uploading " + files.length + " files..."); this.app.notifications.info(
"Uploading " + files.length + " files...",
);
} }
for (let i = 0; i < files.length; i += 1) { for (let i = 0; i < files.length; i += 1) {
@@ -664,7 +733,9 @@
); );
} catch (err) { } catch (err) {
const msg = err && err.message ? err.message : String(err); const msg = err && err.message ? err.message : String(err);
this.app.notifications.error("Upload failed for " + file.name + ": " + msg); this.app.notifications.error(
"Upload failed for " + file.name + ": " + msg,
);
return; return;
} }
} }
@@ -738,7 +809,7 @@
}); });
const listEl = modal.querySelector(".sftp-picker-list"); const listEl = modal.querySelector(".sftp-picker-list");
const breadcrumbEl = modal.querySelector("[data-role=\"breadcrumb\"]"); const breadcrumbEl = modal.querySelector('[data-role="breadcrumb"]');
const pathLabel = modal.querySelector(".sftp-picker-path"); const pathLabel = modal.querySelector(".sftp-picker-path");
const upBtn = modal.querySelector(".sftp-picker-up"); const upBtn = modal.querySelector(".sftp-picker-up");
const refreshBtn = modal.querySelector(".sftp-picker-refresh"); const refreshBtn = modal.querySelector(".sftp-picker-refresh");
@@ -771,7 +842,9 @@
modal modal
.querySelector(".prompt-cancel") .querySelector(".prompt-cancel")
.addEventListener("click", () => close(null)); .addEventListener("click", () => close(null));
useBtn.addEventListener("click", () => close(state.selected || state.cwd)); useBtn.addEventListener("click", () =>
close(state.selected || state.cwd),
);
backdrop.addEventListener("click", (e) => { backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(null); if (e.target === backdrop) close(null);
}); });
@@ -782,7 +855,9 @@
for (let i = 0; i < segments.length; i += 1) { for (let i = 0; i < segments.length; i += 1) {
if (i > 0) { if (i > 0) {
breadcrumbEl.appendChild( breadcrumbEl.appendChild(
createElement("span", "sftp-breadcrumb-sep", { textContent: ">" }), createElement("span", "sftp-breadcrumb-sep", {
textContent: ">",
}),
); );
} }
const seg = segments[i]; const seg = segments[i];
@@ -797,9 +872,13 @@
const updateStatus = () => { const updateStatus = () => {
if (state.selected) { if (state.selected) {
statusLabel.textContent = state.selectedWritable ? "" : "No write permission"; statusLabel.textContent = state.selectedWritable
? ""
: "No write permission";
} else { } else {
statusLabel.textContent = state.cwdWritable ? "" : "Current folder is read only"; statusLabel.textContent = state.cwdWritable
? ""
: "Current folder is read only";
} }
}; };
@@ -807,7 +886,9 @@
listEl.querySelectorAll(".sftp-file-row").forEach((el) => { listEl.querySelectorAll(".sftp-file-row").forEach((el) => {
el.classList.remove("is-selected"); el.classList.remove("is-selected");
}); });
const row = listEl.querySelector(`.sftp-file-row[data-index="${index}"]`); const row = listEl.querySelector(
`.sftp-file-row[data-index="${index}"]`,
);
if (row) row.classList.add("is-selected"); if (row) row.classList.add("is-selected");
state.selected = entry.path; state.selected = entry.path;
state.selectedWritable = entry.canWrite !== false; state.selectedWritable = entry.canWrite !== false;
@@ -821,7 +902,11 @@
const renderList = () => { const renderList = () => {
listEl.innerHTML = ""; listEl.innerHTML = "";
const basePath = state.selected || state.cwd || ""; const basePath = state.selected || state.cwd || "";
const suffix = state.selected ? "" : (state.cwdWritable ? "" : " (read only)"); const suffix = state.selected
? ""
: state.cwdWritable
? ""
: " (read only)";
pathLabel.textContent = basePath + suffix; pathLabel.textContent = basePath + suffix;
if (state.cwd && state.cwd !== "/") { if (state.cwd && state.cwd !== "/") {
@@ -831,18 +916,23 @@
path: this._parentPath(state.cwd), path: this._parentPath(state.cwd),
}); });
parentRow.classList.add("is-parent"); parentRow.classList.add("is-parent");
parentRow.addEventListener("dblclick", () => loadDir(parentRow.dataset.path)); parentRow.addEventListener("dblclick", () =>
loadDir(parentRow.dataset.path),
);
listEl.appendChild(parentRow); listEl.appendChild(parentRow);
} }
const filtered = state.filter const filtered = state.filter
? state.entries.filter((entry) => entry.name.toLowerCase().includes(state.filter)) ? state.entries.filter((entry) =>
entry.name.toLowerCase().includes(state.filter),
)
: state.entries; : state.entries;
state.visibleEntries = filtered; state.visibleEntries = filtered;
if (!filtered.length) { if (!filtered.length) {
const empty = createElement("div", "sftp-state"); const empty = createElement("div", "sftp-state");
empty.innerHTML = "<div class=\"sftp-state-icon\">D</div><div class=\"sftp-state-msg\">No folders found</div>"; empty.innerHTML =
'<div class="sftp-state-icon">D</div><div class="sftp-state-msg">No folders found</div>';
listEl.appendChild(empty); listEl.appendChild(empty);
state.cursorIndex = -1; state.cursorIndex = -1;
updateStatus(); updateStatus();
@@ -873,7 +963,8 @@
const renderLoading = () => { const renderLoading = () => {
listEl.innerHTML = ""; listEl.innerHTML = "";
const s = createElement("div", "sftp-state"); const s = createElement("div", "sftp-state");
s.innerHTML = "<div class=\"sftp-spinner\"></div><div class=\"sftp-state-msg\">Loading...</div>"; s.innerHTML =
'<div class="sftp-spinner"></div><div class="sftp-state-msg">Loading...</div>';
listEl.appendChild(s); listEl.appendChild(s);
}; };
@@ -881,11 +972,14 @@
listEl.innerHTML = ""; listEl.innerHTML = "";
const s = createElement("div", "sftp-state"); const s = createElement("div", "sftp-state");
s.innerHTML = s.innerHTML =
"<div class=\"sftp-state-icon\">!</div>" + '<div class="sftp-state-icon">!</div>' +
"<div class=\"sftp-state-msg\">" + escapeHtml(msg) + "</div>" + '<div class="sftp-state-msg">' +
"<button class=\"btn btn-sm btn-secondary sftp-state-retry\">Retry</button>"; escapeHtml(msg) +
"</div>" +
'<button class="btn btn-sm btn-secondary sftp-state-retry">Retry</button>';
const retryBtn = s.querySelector(".sftp-state-retry"); const retryBtn = s.querySelector(".sftp-state-retry");
if (retryBtn) retryBtn.addEventListener("click", () => loadDir(state.cwd)); if (retryBtn)
retryBtn.addEventListener("click", () => loadDir(state.cwd));
listEl.appendChild(s); listEl.appendChild(s);
}; };
@@ -894,16 +988,27 @@
state.loading = true; state.loading = true;
renderLoading(); renderLoading();
try { try {
const targetPath = path || (await window.go.api.Service.SFTPPwd(sessionId)); const targetPath =
const files = await window.go.api.Service.SFTPList(sessionId, targetPath); path || (await window.go.api.Service.SFTPPwd(sessionId));
const stat = await window.go.api.Service.SFTPStat(sessionId, targetPath); const files = await window.go.api.Service.SFTPList(
sessionId,
targetPath,
);
const stat = await window.go.api.Service.SFTPStat(
sessionId,
targetPath,
);
state.cwd = targetPath; state.cwd = targetPath;
state.selected = ""; state.selected = "";
state.selectedWritable = true; state.selectedWritable = true;
state.cwdWritable = stat && this._modeCanWrite(stat.mode); state.cwdWritable = stat && this._modeCanWrite(stat.mode);
state.entries = (files || []).filter((e) => e.isDir).sort((a, b) => state.entries = (files || [])
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), .filter((e) => e.isDir)
); .sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
sensitivity: "base",
}),
);
state.entries = state.entries.map((entry) => { state.entries = state.entries.map((entry) => {
const canRead = this._modeCanRead(entry.mode); const canRead = this._modeCanRead(entry.mode);
const canWrite = this._modeCanWrite(entry.mode); const canWrite = this._modeCanWrite(entry.mode);
@@ -972,9 +1077,10 @@
} }
}); });
const initial = this.app.sftp && typeof this.app.sftp.getCwd === "function" const initial =
? this.app.sftp.getCwd(sessionId) this.app.sftp && typeof this.app.sftp.getCwd === "function"
: ""; ? this.app.sftp.getCwd(sessionId)
: "";
loadDir(initial || ""); loadDir(initial || "");
}); });
} }
@@ -986,7 +1092,9 @@
dataset.index = String(index); dataset.index = String(index);
} }
const row = createElement("div", "sftp-file-row", { dataset }); const row = createElement("div", "sftp-file-row", { dataset });
const icon = createElement("span", "sftp-file-icon is-dir", { textContent: "DIR" }); const icon = createElement("span", "sftp-file-icon is-dir", {
textContent: "DIR",
});
const name = createElement("span", "sftp-file-name", { const name = createElement("span", "sftp-file-name", {
textContent: entry.name || entry.path || "", textContent: entry.name || entry.path || "",
title: entry.name || entry.path || "", title: entry.name || entry.path || "",
@@ -997,7 +1105,9 @@
} else if (entry.canWrite === false) { } else if (entry.canWrite === false) {
metaLabel = "READ ONLY"; metaLabel = "READ ONLY";
} }
const meta = createElement("span", "sftp-file-meta", { textContent: metaLabel }); const meta = createElement("span", "sftp-file-meta", {
textContent: metaLabel,
});
row.appendChild(icon); row.appendChild(icon);
row.appendChild(name); row.appendChild(name);
row.appendChild(meta); row.appendChild(meta);
+12
View File
@@ -41,6 +41,10 @@ export function ExportProfiles(arg1:boolean):Promise<string>;
export function ExportProfilesEncrypted(arg1:Array<string>,arg2:string):Promise<string>; export function ExportProfilesEncrypted(arg1:Array<string>,arg2:string):Promise<string>;
export function FileExists(arg1:string):Promise<boolean>;
export function FileIsPrivateKey(arg1:string):Promise<boolean>;
export function GetAppInfo():Promise<Record<string, string>>; export function GetAppInfo():Promise<Record<string, string>>;
export function GetPreferences():Promise<models.AppPreferences>; export function GetPreferences():Promise<models.AppPreferences>;
@@ -49,6 +53,8 @@ export function GetProfile(arg1:string):Promise<models.Profile>;
export function GetRecording(arg1:string):Promise<Record<string, any>>; export function GetRecording(arg1:string):Promise<Record<string, any>>;
export function GetSessionCwd(arg1:string):Promise<string>;
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>; export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
export function GetTOTPCode(arg1:string):Promise<string>; export function GetTOTPCode(arg1:string):Promise<string>;
@@ -87,6 +93,8 @@ export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
export function ListRecordings():Promise<Array<Record<string, any>>>; export function ListRecordings():Promise<Array<Record<string, any>>>;
export function ListSSHKeys():Promise<Array<string>>;
export function ListSessions():Promise<Array<models.SessionInfo>>; export function ListSessions():Promise<Array<models.SessionInfo>>;
export function ListSnippets():Promise<Array<models.Snippet>>; export function ListSnippets():Promise<Array<models.Snippet>>;
@@ -119,8 +127,12 @@ export function SFTPRemove(arg1:string,arg2:string):Promise<void>;
export function SFTPRename(arg1:string,arg2:string,arg3:string):Promise<void>; export function SFTPRename(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SFTPStat(arg1:string,arg2:string):Promise<Record<string, any>>;
export function SFTPUpload(arg1:string,arg2:string):Promise<void>; export function SFTPUpload(arg1:string,arg2:string):Promise<void>;
export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
export function SaveFile(arg1:string,arg2:string):Promise<string>; export function SaveFile(arg1:string,arg2:string):Promise<string>;
export function SavePreferences(arg1:models.AppPreferences):Promise<void>; export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
+24
View File
@@ -78,6 +78,14 @@ export function ExportProfilesEncrypted(arg1, arg2) {
return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2); return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2);
} }
export function FileExists(arg1) {
return window['go']['api']['Service']['FileExists'](arg1);
}
export function FileIsPrivateKey(arg1) {
return window['go']['api']['Service']['FileIsPrivateKey'](arg1);
}
export function GetAppInfo() { export function GetAppInfo() {
return window['go']['api']['Service']['GetAppInfo'](); return window['go']['api']['Service']['GetAppInfo']();
} }
@@ -94,6 +102,10 @@ export function GetRecording(arg1) {
return window['go']['api']['Service']['GetRecording'](arg1); return window['go']['api']['Service']['GetRecording'](arg1);
} }
export function GetSessionCwd(arg1) {
return window['go']['api']['Service']['GetSessionCwd'](arg1);
}
export function GetSessionHistory(arg1) { export function GetSessionHistory(arg1) {
return window['go']['api']['Service']['GetSessionHistory'](arg1); return window['go']['api']['Service']['GetSessionHistory'](arg1);
} }
@@ -170,6 +182,10 @@ export function ListRecordings() {
return window['go']['api']['Service']['ListRecordings'](); return window['go']['api']['Service']['ListRecordings']();
} }
export function ListSSHKeys() {
return window['go']['api']['Service']['ListSSHKeys']();
}
export function ListSessions() { export function ListSessions() {
return window['go']['api']['Service']['ListSessions'](); return window['go']['api']['Service']['ListSessions']();
} }
@@ -234,10 +250,18 @@ export function SFTPRename(arg1, arg2, arg3) {
return window['go']['api']['Service']['SFTPRename'](arg1, arg2, arg3); return window['go']['api']['Service']['SFTPRename'](arg1, arg2, arg3);
} }
export function SFTPStat(arg1, arg2) {
return window['go']['api']['Service']['SFTPStat'](arg1, arg2);
}
export function SFTPUpload(arg1, arg2) { export function SFTPUpload(arg1, arg2) {
return window['go']['api']['Service']['SFTPUpload'](arg1, arg2); return window['go']['api']['Service']['SFTPUpload'](arg1, arg2);
} }
export function SFTPUploadData(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['SFTPUploadData'](arg1, arg2, arg3, arg4);
}
export function SaveFile(arg1, arg2) { export function SaveFile(arg1, arg2) {
return window['go']['api']['Service']['SaveFile'](arg1, arg2); return window['go']['api']['Service']['SaveFile'](arg1, arg2);
} }
+119
View File
@@ -3,14 +3,17 @@
package api package api
import ( import (
"bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
"os"
"path" "path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -145,6 +148,99 @@ func (s *Service) ListProfiles() ([]models.Profile, error) {
return derefProfiles(profiles), nil return derefProfiles(profiles), nil
} }
// ListSSHKeys returns absolute paths to candidate SSH private keys in ~/.ssh.
func (s *Service) ListSSHKeys() ([]string, error) {
home, err := os.UserHomeDir()
if err != nil {
return []string{}, nil
}
dir := filepath.Join(home, ".ssh")
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
}
ignored := map[string]bool{
"authorized_keys": true,
"authorized_keys2": true,
"config": true,
"known_hosts": true,
"known_hosts.old": true,
"ssh_config": true,
"ssh_known_hosts": true,
"ssh_known_hosts2": true,
}
keys := make([]string, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if ignored[name] {
continue
}
if strings.HasSuffix(name, ".pub") {
continue
}
if strings.HasSuffix(name, ".old") {
continue
}
fullPath := filepath.Join(dir, name)
if !isLikelyPrivateKey(fullPath) {
continue
}
keys = append(keys, fullPath)
}
sort.Strings(keys)
return keys, nil
}
func isLikelyPrivateKey(path string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil && n == 0 {
return false
}
head := buf[:n]
if bytes.HasPrefix(head, []byte("openssh-key-v1")) {
return true
}
if bytes.Contains(head, []byte("BEGIN OPENSSH PRIVATE KEY")) {
return true
}
if bytes.Contains(head, []byte("BEGIN RSA PRIVATE KEY")) {
return true
}
if bytes.Contains(head, []byte("BEGIN DSA PRIVATE KEY")) {
return true
}
if bytes.Contains(head, []byte("BEGIN EC PRIVATE KEY")) {
return true
}
if bytes.Contains(head, []byte("BEGIN ED25519 PRIVATE KEY")) {
return true
}
if bytes.Contains(head, []byte("BEGIN PRIVATE KEY")) {
return true
}
return false
}
// SearchProfiles searches profiles by display name, host, or tags. // SearchProfiles searches profiles by display name, host, or tags.
func (s *Service) SearchProfiles(query string) ([]models.Profile, error) { func (s *Service) SearchProfiles(query string) ([]models.Profile, error) {
profiles, err := s.store.SearchProfiles(query) profiles, err := s.store.SearchProfiles(query)
@@ -733,6 +829,29 @@ func (s *Service) SaveFile(title string, defaultName string) (string, error) {
return path, nil return path, nil
} }
// FileExists returns true if the path exists and is not a directory.
func (s *Service) FileExists(path string) (bool, error) {
if path == "" {
return false, nil
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return !info.IsDir(), nil
}
// FileIsPrivateKey returns true if the path appears to be an SSH private key.
func (s *Service) FileIsPrivateKey(path string) (bool, error) {
if path == "" {
return false, nil
}
return isLikelyPrivateKey(path), nil
}
// GetAppInfo returns build and environment information. // GetAppInfo returns build and environment information.
func (s *Service) GetAppInfo() map[string]string { func (s *Service) GetAppInfo() map[string]string {
return map[string]string{ return map[string]string{