diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index c3ac259..a4d8e49 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -156,8 +156,9 @@
@@ -167,13 +168,15 @@ (keydown.enter)="commitPath()" (keydown.escape)="resetPath()" (keydown)="$event.stopPropagation()" + [disabled]="blocked()" spellcheck="false" - class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring" + class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" /> +
+ + } @else { @for (entry of panel().sorted(); track entry.name; let i = $index) {
No matches
} + } + +@if (panel().slowLoad() && !panel().connecting()) { +
+ +
+} diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 8237812..d5b2d05 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -37,6 +37,8 @@ export class FilePanel { readonly panel = computed(() => this.panels.get(this.side())); readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isRemote = computed(() => this.panel().sessionId() !== 'local'); + /** Input is ignored while the panel is connecting or a listing is in flight. */ + readonly blocked = computed(() => this.panel().busy()); /** The copy button points toward the other panel. */ readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft')); @@ -63,12 +65,18 @@ export class FilePanel { this.panels.setActiveSide(this.side()); } + cancelConnect(): void { + this.panels.cancelConnect(this.side()); + } + copyToOther(): void { + if (this.blocked()) return; this.activate(); void this.transfers.copyToOtherPanel(this.side()); } run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void { + if (this.blocked()) return; this.activate(); void this.actions[action](this.panel()); } @@ -148,6 +156,7 @@ export class FilePanel { } commitPath(): void { + if (this.blocked()) return; const value = this.pathDraft().trim(); if (value) void this.panel().load(value); this.listEl().nativeElement.focus(); @@ -159,6 +168,7 @@ export class FilePanel { } rowClick(event: MouseEvent, index: number): void { + if (this.blocked()) return; this.activate(); const panel = this.panel(); if (event.shiftKey) { @@ -176,6 +186,7 @@ export class FilePanel { rowContextMenu(event: MouseEvent, index: number): void { event.preventDefault(); event.stopPropagation(); + if (this.blocked()) return; this.activate(); const panel = this.panel(); const entry = panel.sorted()[index] ?? null; @@ -187,16 +198,19 @@ export class FilePanel { listContextMenu(event: MouseEvent): void { event.preventDefault(); + if (this.blocked()) return; this.activate(); void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null }); } openAt(index: number): void { + if (this.blocked()) return; const entry = this.panel().sorted()[index]; if (entry) this.panel().open(entry); } onKeydown(event: KeyboardEvent): void { + if (this.blocked()) return; const panel = this.panel(); const key = event.key; diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index eab0f9f..98d6df4 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -17,9 +17,18 @@ export class PanelState { readonly fsKey = signal('local'); readonly title = signal('Local'); readonly connecting = signal(false); + /** Name shown in the "Connecting to X…" placeholder while connecting. */ + readonly connectingTo = signal(null); readonly cwd = signal(''); readonly entries = signal([]); readonly loading = signal(false); + /** True once an in-flight load has taken >300ms — drives the dim overlay. */ + readonly slowLoad = signal(false); + /** Bumping this discards the result of any in-flight connect (cancel/supersede). */ + connectToken = 0; + + /** Input on this panel is ignored while it's connecting or loading. */ + readonly busy = computed(() => this.connecting() || this.loading()); readonly sortKey = signal('name'); readonly sortAsc = signal(true); readonly cursor = signal(0); @@ -65,6 +74,7 @@ export class PanelState { async load(path: string | null): Promise { this.loading.set(true); + const slowTimer = setTimeout(() => this.slowLoad.set(true), 300); try { const res = await this.deps.rpc.request.listDir({ sessionId: this.sessionId(), path }); if (!res.ok) { @@ -77,6 +87,8 @@ export class PanelState { this.selected.set(new Set()); this.filter.set(''); } finally { + clearTimeout(slowTimer); + this.slowLoad.set(false); this.loading.set(false); } } @@ -86,6 +98,7 @@ export class PanelState { const keepCursorName = this.sorted()[this.cursor()]?.name; const keepSelected = this.selected(); this.loading.set(true); + const slowTimer = setTimeout(() => this.slowLoad.set(true), 300); try { const res = await this.deps.rpc.request.listDir({ sessionId: this.sessionId(), @@ -102,6 +115,8 @@ export class PanelState { const idx = this.sorted().findIndex((e) => e.name === keepCursorName); this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1))); } finally { + clearTimeout(slowTimer); + this.slowLoad.set(false); this.loading.set(false); } } @@ -349,9 +364,16 @@ export class PanelsService { /** Connect a panel to a server; replaces (and disconnects) its previous remote session. */ async connectPanel(side: Side, config: ConnectionConfig, title: string): Promise { const panel = this.get(side); + const token = ++panel.connectToken; panel.connecting.set(true); + panel.connectingTo.set(title); try { const res = await this.rpc.request.connect({ config }); + if (panel.connectToken !== token) { + // cancelled or superseded while waiting — clean up the orphan session + if (res.ok) void this.rpc.request.disconnect({ sessionId: res.sessionId }); + return false; + } if (!res.ok) { this.toast.error(`${title}: ${res.error}`); return false; @@ -366,10 +388,22 @@ export class PanelsService { await panel.load(config.initialPath ?? res.homeDir); return true; } finally { - panel.connecting.set(false); + if (panel.connectToken === token) { + panel.connecting.set(false); + panel.connectingTo.set(null); + } } } + /** Abort waiting on an in-flight connect; the panel keeps its previous state. */ + cancelConnect(side: Side): void { + const panel = this.get(side); + if (!panel.connecting()) return; + panel.connectToken++; + panel.connecting.set(false); + panel.connectingTo.set(null); + } + async disconnectPanel(side: Side): Promise { const panel = this.get(side); const sessionId = panel.sessionId(); diff --git a/ui/src/styles.css b/ui/src/styles.css index 59b42c9..db1af44 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -188,6 +188,16 @@ --action-icon: 1.05rem; } +/* Soft entrance for the panel busy states (connecting placeholder, load overlay). */ +@keyframes panel-fade-in { + from { + opacity: 0; + } +} +.panel-fade-in { + animation: panel-fade-in 0.18s ease-out; +} + /* Accent tone (data-accent set by SettingsService; absent = graphite/zinc). Each tone is one hue+chroma line; the formula blocks below derive --primary, --ring and the cursor-row tint for light and both dark states. The dark