Panel busy states: Connecting placeholder with Cancel, slow-load overlay
Connecting blanks the list to a fading spinner with the target name and a Cancel button (token discards late results and orphan sessions). Listings >300ms dim the list under a spinner. All panel input is blocked while busy; the source picker stays usable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -156,8 +156,9 @@
|
|||||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||||
<button
|
<button
|
||||||
(click)="panel().up()"
|
(click)="panel().up()"
|
||||||
|
[disabled]="blocked()"
|
||||||
title="Parent directory (Backspace)"
|
title="Parent directory (Backspace)"
|
||||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaArrowUp" size="1rem" />
|
<ng-icon name="mynaArrowUp" size="1rem" />
|
||||||
</button>
|
</button>
|
||||||
@@ -167,13 +168,15 @@
|
|||||||
(keydown.enter)="commitPath()"
|
(keydown.enter)="commitPath()"
|
||||||
(keydown.escape)="resetPath()"
|
(keydown.escape)="resetPath()"
|
||||||
(keydown)="$event.stopPropagation()"
|
(keydown)="$event.stopPropagation()"
|
||||||
|
[disabled]="blocked()"
|
||||||
spellcheck="false"
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
(click)="panel().refresh()"
|
(click)="panel().refresh()"
|
||||||
|
[disabled]="blocked()"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
@if (panel().loading()) {
|
@if (panel().loading()) {
|
||||||
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
||||||
@@ -217,14 +220,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- file list -->
|
<!-- file list -->
|
||||||
|
<div class="relative min-h-0 flex-1">
|
||||||
<div
|
<div
|
||||||
#list
|
#list
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
(keydown)="onKeydown($event)"
|
(keydown)="onKeydown($event)"
|
||||||
(mousedown)="activate()"
|
(mousedown)="activate()"
|
||||||
(contextmenu)="listContextMenu($event)"
|
(contextmenu)="listContextMenu($event)"
|
||||||
class="flex-1 overflow-y-auto outline-none"
|
class="h-full overflow-y-auto outline-none"
|
||||||
>
|
>
|
||||||
|
@if (panel().connecting()) {
|
||||||
|
<div class="panel-fade-in grid h-full place-items-center">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
Connecting to {{ panel().connectingTo() }}…
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
(click)="cancelConnect()"
|
||||||
|
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
||||||
<div
|
<div
|
||||||
[attr.data-index]="i"
|
[attr.data-index]="i"
|
||||||
@@ -255,4 +275,11 @@
|
|||||||
@if (panel().filter() && filterMatches() === 0) {
|
@if (panel().filter() && filterMatches() === 0) {
|
||||||
<div class="p-6 text-center text-xs text-muted-foreground">No matches</div>
|
<div class="p-6 text-center text-xs text-muted-foreground">No matches</div>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (panel().slowLoad() && !panel().connecting()) {
|
||||||
|
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
|
||||||
|
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export class FilePanel {
|
|||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
readonly panel = computed(() => this.panels.get(this.side()));
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||||
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
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. */
|
/** The copy button points toward the other panel. */
|
||||||
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
|
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
|
||||||
|
|
||||||
@@ -63,12 +65,18 @@ export class FilePanel {
|
|||||||
this.panels.setActiveSide(this.side());
|
this.panels.setActiveSide(this.side());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelConnect(): void {
|
||||||
|
this.panels.cancelConnect(this.side());
|
||||||
|
}
|
||||||
|
|
||||||
copyToOther(): void {
|
copyToOther(): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
this.activate();
|
this.activate();
|
||||||
void this.transfers.copyToOtherPanel(this.side());
|
void this.transfers.copyToOtherPanel(this.side());
|
||||||
}
|
}
|
||||||
|
|
||||||
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
this.activate();
|
this.activate();
|
||||||
void this.actions[action](this.panel());
|
void this.actions[action](this.panel());
|
||||||
}
|
}
|
||||||
@@ -148,6 +156,7 @@ export class FilePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
commitPath(): void {
|
commitPath(): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
const value = this.pathDraft().trim();
|
const value = this.pathDraft().trim();
|
||||||
if (value) void this.panel().load(value);
|
if (value) void this.panel().load(value);
|
||||||
this.listEl().nativeElement.focus();
|
this.listEl().nativeElement.focus();
|
||||||
@@ -159,6 +168,7 @@ export class FilePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rowClick(event: MouseEvent, index: number): void {
|
rowClick(event: MouseEvent, index: number): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
this.activate();
|
this.activate();
|
||||||
const panel = this.panel();
|
const panel = this.panel();
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
@@ -176,6 +186,7 @@ export class FilePanel {
|
|||||||
rowContextMenu(event: MouseEvent, index: number): void {
|
rowContextMenu(event: MouseEvent, index: number): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (this.blocked()) return;
|
||||||
this.activate();
|
this.activate();
|
||||||
const panel = this.panel();
|
const panel = this.panel();
|
||||||
const entry = panel.sorted()[index] ?? null;
|
const entry = panel.sorted()[index] ?? null;
|
||||||
@@ -187,16 +198,19 @@ export class FilePanel {
|
|||||||
|
|
||||||
listContextMenu(event: MouseEvent): void {
|
listContextMenu(event: MouseEvent): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (this.blocked()) return;
|
||||||
this.activate();
|
this.activate();
|
||||||
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null });
|
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
openAt(index: number): void {
|
openAt(index: number): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
const entry = this.panel().sorted()[index];
|
const entry = this.panel().sorted()[index];
|
||||||
if (entry) this.panel().open(entry);
|
if (entry) this.panel().open(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (this.blocked()) return;
|
||||||
const panel = this.panel();
|
const panel = this.panel();
|
||||||
const key = event.key;
|
const key = event.key;
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,18 @@ export class PanelState {
|
|||||||
readonly fsKey = signal('local');
|
readonly fsKey = signal('local');
|
||||||
readonly title = signal('Local');
|
readonly title = signal('Local');
|
||||||
readonly connecting = signal(false);
|
readonly connecting = signal(false);
|
||||||
|
/** Name shown in the "Connecting to X…" placeholder while connecting. */
|
||||||
|
readonly connectingTo = signal<string | null>(null);
|
||||||
readonly cwd = signal('');
|
readonly cwd = signal('');
|
||||||
readonly entries = signal<FileEntry[]>([]);
|
readonly entries = signal<FileEntry[]>([]);
|
||||||
readonly loading = signal(false);
|
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<SortKey>('name');
|
readonly sortKey = signal<SortKey>('name');
|
||||||
readonly sortAsc = signal(true);
|
readonly sortAsc = signal(true);
|
||||||
readonly cursor = signal(0);
|
readonly cursor = signal(0);
|
||||||
@@ -65,6 +74,7 @@ export class PanelState {
|
|||||||
|
|
||||||
async load(path: string | null): Promise<void> {
|
async load(path: string | null): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
|
||||||
try {
|
try {
|
||||||
const res = await this.deps.rpc.request.listDir({ sessionId: this.sessionId(), path });
|
const res = await this.deps.rpc.request.listDir({ sessionId: this.sessionId(), path });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -77,6 +87,8 @@ export class PanelState {
|
|||||||
this.selected.set(new Set());
|
this.selected.set(new Set());
|
||||||
this.filter.set('');
|
this.filter.set('');
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(slowTimer);
|
||||||
|
this.slowLoad.set(false);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,6 +98,7 @@ export class PanelState {
|
|||||||
const keepCursorName = this.sorted()[this.cursor()]?.name;
|
const keepCursorName = this.sorted()[this.cursor()]?.name;
|
||||||
const keepSelected = this.selected();
|
const keepSelected = this.selected();
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
|
||||||
try {
|
try {
|
||||||
const res = await this.deps.rpc.request.listDir({
|
const res = await this.deps.rpc.request.listDir({
|
||||||
sessionId: this.sessionId(),
|
sessionId: this.sessionId(),
|
||||||
@@ -102,6 +115,8 @@ export class PanelState {
|
|||||||
const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
|
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)));
|
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(slowTimer);
|
||||||
|
this.slowLoad.set(false);
|
||||||
this.loading.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. */
|
/** Connect a panel to a server; replaces (and disconnects) its previous remote session. */
|
||||||
async connectPanel(side: Side, config: ConnectionConfig, title: string): Promise<boolean> {
|
async connectPanel(side: Side, config: ConnectionConfig, title: string): Promise<boolean> {
|
||||||
const panel = this.get(side);
|
const panel = this.get(side);
|
||||||
|
const token = ++panel.connectToken;
|
||||||
panel.connecting.set(true);
|
panel.connecting.set(true);
|
||||||
|
panel.connectingTo.set(title);
|
||||||
try {
|
try {
|
||||||
const res = await this.rpc.request.connect({ config });
|
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) {
|
if (!res.ok) {
|
||||||
this.toast.error(`${title}: ${res.error}`);
|
this.toast.error(`${title}: ${res.error}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -366,10 +388,22 @@ export class PanelsService {
|
|||||||
await panel.load(config.initialPath ?? res.homeDir);
|
await panel.load(config.initialPath ?? res.homeDir);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} 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<void> {
|
async disconnectPanel(side: Side): Promise<void> {
|
||||||
const panel = this.get(side);
|
const panel = this.get(side);
|
||||||
const sessionId = panel.sessionId();
|
const sessionId = panel.sessionId();
|
||||||
|
|||||||
@@ -188,6 +188,16 @@
|
|||||||
--action-icon: 1.05rem;
|
--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).
|
/* Accent tone (data-accent set by SettingsService; absent = graphite/zinc).
|
||||||
Each tone is one hue+chroma line; the formula blocks below derive --primary,
|
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
|
--ring and the cursor-row tint for light and both dark states. The dark
|
||||||
|
|||||||
Reference in New Issue
Block a user