Native menus (electrobun ContextMenu) on rows and empty panel space. Rows: Open (dirs navigate; local files open with the default app via Utils.openPath), Edit, Copy to Other Panel, Rename, Permissions, Copy Path (works for remote paths too), Reveal in Finder (local, Utils.showItemInFolder), Move to Trash (local, Utils.moveToTrash — tooltip notes Finder Restore doesn't apply) and Delete. Empty space / '..': New Folder, New File, Refresh, Reveal in Finder. Right-click parks the cursor on the row (keeping the selection when clicking inside it). Local shell actions run bun-side directly; everything else routes through the existing menuAction channel back to the stored click target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
251 lines
9.8 KiB
HTML
251 lines
9.8 KiB
HTML
<!-- session strip -->
|
|
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1">
|
|
<button
|
|
(click)="menuOpen.set(!menuOpen())"
|
|
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
|
|
title="Choose what this panel shows"
|
|
>
|
|
@if (panel().connecting()) {
|
|
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="0.9rem" />
|
|
} @else {
|
|
<ng-icon
|
|
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
|
|
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
|
size="0.9rem"
|
|
/>
|
|
}
|
|
<span class="truncate">{{ panel().title() }}</span>
|
|
<ng-icon name="mynaChevronDown" class="shrink-0 text-muted-foreground" size="0.8rem" />
|
|
</button>
|
|
@if (isRemote()) {
|
|
<button
|
|
(click)="disconnect()"
|
|
title="Disconnect (back to local)"
|
|
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaLogout" size="0.85rem" />
|
|
</button>
|
|
}
|
|
|
|
<div class="flex-1"></div>
|
|
|
|
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
|
labels when the panel is wide enough (container query). -->
|
|
<button
|
|
(click)="copyToOther()"
|
|
[title]="side() === 'left' ? 'Copy to right panel (F5)' : 'Copy to left panel (F5)'"
|
|
class="flex h-6 shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
|
|
>
|
|
<ng-icon [name]="copyIcon()" size="0.95rem" />
|
|
<span class="ml-1 hidden text-[11px] @[30rem]:inline">Copy</span>
|
|
</button>
|
|
<button
|
|
(click)="run('edit')"
|
|
title="Edit file (F4)"
|
|
class="flex h-6 shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaFileText" size="0.9rem" />
|
|
<span class="ml-1 hidden text-[11px] @[30rem]:inline">Edit</span>
|
|
</button>
|
|
<button
|
|
(click)="run('rename')"
|
|
title="Rename (F2)"
|
|
class="flex h-6 shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaEdit" size="0.9rem" />
|
|
<span class="ml-1 hidden text-[11px] @[30rem]:inline">Rename</span>
|
|
</button>
|
|
<button
|
|
(click)="run('mkdir')"
|
|
title="New folder (F7)"
|
|
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaFolderPlus" size="0.9rem" />
|
|
</button>
|
|
<button
|
|
(click)="run('createFile')"
|
|
title="New file"
|
|
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaFilePlus" size="0.9rem" />
|
|
</button>
|
|
<button
|
|
(click)="run('chmod')"
|
|
title="Permissions"
|
|
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaLock" size="0.9rem" />
|
|
</button>
|
|
<button
|
|
(click)="run('delete')"
|
|
title="Delete (F8 / Del)"
|
|
class="flex h-6 shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
>
|
|
<ng-icon name="mynaTrash" size="0.9rem" />
|
|
<span class="ml-1 hidden text-[11px] @[30rem]:inline">Delete</span>
|
|
</button>
|
|
|
|
@if (menuOpen()) {
|
|
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
|
<div
|
|
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
|
>
|
|
<button
|
|
(click)="pickLocal()"
|
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
|
>
|
|
<ng-icon name="mynaHardDrive" size="0.9rem" class="shrink-0 text-muted-foreground" />
|
|
<span class="flex-1">Local</span>
|
|
@if (!isRemote()) {
|
|
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
|
}
|
|
</button>
|
|
|
|
@if (connections.grouped().root.length || connections.grouped().folders.length) {
|
|
<div class="my-1 border-t border-border"></div>
|
|
}
|
|
@for (connection of connections.grouped().root; track connection.id) {
|
|
<button
|
|
(click)="pickConnection(connection)"
|
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
|
>
|
|
<ng-icon name="mynaServers" size="0.9rem" class="shrink-0 text-muted-foreground" />
|
|
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
</button>
|
|
}
|
|
@for (group of connections.grouped().folders; track group.folder.id) {
|
|
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
|
{{ group.folder.name }}
|
|
</div>
|
|
@for (connection of group.connections; track connection.id) {
|
|
<button
|
|
(click)="pickConnection(connection)"
|
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-xs hover:bg-accent"
|
|
>
|
|
<ng-icon name="mynaServers" size="0.9rem" class="shrink-0 text-muted-foreground" />
|
|
<span class="flex-1 truncate">{{ connection.name }}</span>
|
|
</button>
|
|
}
|
|
}
|
|
|
|
<div class="my-1 border-t border-border"></div>
|
|
<button
|
|
(click)="openManager()"
|
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
|
>
|
|
<ng-icon name="mynaCog" size="0.9rem" class="shrink-0 text-muted-foreground" />
|
|
Manage connections…
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<!-- path bar -->
|
|
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
<button
|
|
(click)="panel().up()"
|
|
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"
|
|
>
|
|
<ng-icon name="mynaArrowUp" size="1rem" />
|
|
</button>
|
|
<input
|
|
[value]="pathDraft()"
|
|
(input)="pathDraft.set($any($event.target).value)"
|
|
(keydown.enter)="commitPath()"
|
|
(keydown.escape)="resetPath()"
|
|
(keydown)="$event.stopPropagation()"
|
|
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"
|
|
/>
|
|
<button
|
|
(click)="panel().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"
|
|
>
|
|
@if (panel().loading()) {
|
|
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
|
} @else {
|
|
<ng-icon name="mynaRefresh" size="1rem" />
|
|
}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- column headers -->
|
|
<div
|
|
class="grid grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] border-b border-border text-[11px] font-medium text-muted-foreground"
|
|
>
|
|
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
|
Name {{ sortIndicator('name') }}
|
|
</button>
|
|
<button class="px-2 py-1 text-right hover:text-foreground" (click)="panel().toggleSort('size')">
|
|
Size {{ sortIndicator('size') }}
|
|
</button>
|
|
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('mtime')">
|
|
Modified {{ sortIndicator('mtime') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- type-to-filter bar (appears while a query is active) -->
|
|
@if (panel().filter(); as query) {
|
|
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
|
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
|
<span class="shrink-0 text-[11px] text-muted-foreground">
|
|
{{ filterMatches() }} match{{ filterMatches() === 1 ? '' : 'es' }} · Esc
|
|
</span>
|
|
<button
|
|
(click)="clearFilter()"
|
|
title="Clear filter (Esc)"
|
|
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaX" size="0.8rem" />
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
<!-- file list -->
|
|
<div
|
|
#list
|
|
tabindex="0"
|
|
(keydown)="onKeydown($event)"
|
|
(mousedown)="activate()"
|
|
(contextmenu)="listContextMenu($event)"
|
|
class="flex-1 overflow-y-auto outline-none"
|
|
>
|
|
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
|
<div
|
|
[attr.data-index]="i"
|
|
(click)="rowClick($event, i)"
|
|
(dblclick)="openAt(i)"
|
|
(contextmenu)="rowContextMenu($event, i)"
|
|
class="grid h-(--row-h) grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] items-center text-(length:--list-font)"
|
|
[class.bg-accent]="i === panel().cursor() && !isSelected(entry)"
|
|
[class.bg-primary/15]="isSelected(entry)"
|
|
[class.ring-1]="i === panel().cursor() && isActive()"
|
|
[class.ring-inset]="i === panel().cursor() && isActive()"
|
|
[class.ring-ring]="i === panel().cursor() && isActive()"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-1.5 px-2">
|
|
<ng-icon
|
|
[name]="iconFor(entry)"
|
|
size="0.95rem"
|
|
[class]="entry.type === 'dir' ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
|
/>
|
|
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
|
</div>
|
|
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
|
|
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
|
|
</span>
|
|
<span class="px-2 font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
|
|
{{ formatDate(entry.mtime) }}
|
|
</span>
|
|
</div>
|
|
} @empty {
|
|
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
|
}
|
|
@if (panel().filter() && filterMatches() === 0) {
|
|
<div class="p-6 text-center text-xs text-muted-foreground">No matches</div>
|
|
}
|
|
</div>
|