From bb97e5418fe3997782f8525636009c65b02fca6f Mon Sep 17 00:00:00 2001 From: Jokin Date: Sun, 16 Aug 2026 04:56:20 +0200 Subject: [PATCH] Stage 4: restore the modern tree into app/skins/shadlike/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both UIs now live side by side. The 18 modern component files come straight from `git show main:` — recovered, not rewritten, which is possible because main's tip IS the merge base of this branch. There is no title-bar among them: that component is win7-only, since the modern skin defers to the native window frame. Their imports were rewritten to @core the same way stage 1 did, leaving only skin-internal relative imports behind. Added skins/shadlike/index.ts exporting SHAD_ROOTS with the same shape as SEVEN_ROOTS, so main.ts can select a tree without knowing either skin's internals. Restored the skin axis in core/platform.ts, deleted in fe028a9 and needed again by the modern tree: __APP_SKIN on the Window global, APP_SKIN, IS_WIN7_CHROME, and IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME. That last one is what reserves pl-24 for the macOS traffic lights in the fused hiddenInset header — and correctly stops reserving it under win7, where titleBarStyle 'hidden' removes the traffic lights entirely. The tree is not reachable yet; the build is the gate. I checked that the gate is real rather than assuming it: injecting a type error into an unreferenced shadlike file does fail `bun run build:ui`, so ngtsc type-checks the whole program including components nothing imports. Without that check, "the build passes" would have proven nothing about 18 files no one references. win7 remains pixel-identical to the pre-refactor baseline. Co-Authored-By: Claude --- ui/src/app/core/platform.ts | 23 + ui/src/app/skins/shadlike/app.html | 58 ++ ui/src/app/skins/shadlike/app.ts | 231 ++++++++ .../connection-manager.html | 384 +++++++++++++ .../connection-manager/connection-manager.ts | 271 +++++++++ ui/src/app/skins/shadlike/context-menu.ts | 167 ++++++ .../skins/shadlike/dialogs/chmod-dialog.ts | 129 +++++ .../skins/shadlike/dialogs/confirm-dialog.ts | 66 +++ .../shadlike/dialogs/overwrite-dialog.ts | 149 +++++ .../skins/shadlike/dialogs/prompt-dialog.ts | 86 +++ .../shadlike/editor-window/editor-window.html | 209 +++++++ .../shadlike/editor-window/editor-window.ts | 348 +++++++++++ .../skins/shadlike/file-panel/file-panel.html | 327 +++++++++++ .../skins/shadlike/file-panel/file-panel.ts | 361 ++++++++++++ ui/src/app/skins/shadlike/index.ts | 17 + .../skins/shadlike/status-bar/status-bar.ts | 78 +++ ui/src/app/skins/shadlike/tab-bar/tab-bar.ts | 540 ++++++++++++++++++ ui/src/app/skins/shadlike/toasts/toasts.ts | 50 ++ .../shadlike/transfer-bar/transfer-bar.html | 50 ++ .../shadlike/transfer-bar/transfer-bar.ts | 82 +++ 20 files changed, 3626 insertions(+) create mode 100644 ui/src/app/skins/shadlike/app.html create mode 100644 ui/src/app/skins/shadlike/app.ts create mode 100644 ui/src/app/skins/shadlike/connection-manager/connection-manager.html create mode 100644 ui/src/app/skins/shadlike/connection-manager/connection-manager.ts create mode 100644 ui/src/app/skins/shadlike/context-menu.ts create mode 100644 ui/src/app/skins/shadlike/dialogs/chmod-dialog.ts create mode 100644 ui/src/app/skins/shadlike/dialogs/confirm-dialog.ts create mode 100644 ui/src/app/skins/shadlike/dialogs/overwrite-dialog.ts create mode 100644 ui/src/app/skins/shadlike/dialogs/prompt-dialog.ts create mode 100644 ui/src/app/skins/shadlike/editor-window/editor-window.html create mode 100644 ui/src/app/skins/shadlike/editor-window/editor-window.ts create mode 100644 ui/src/app/skins/shadlike/file-panel/file-panel.html create mode 100644 ui/src/app/skins/shadlike/file-panel/file-panel.ts create mode 100644 ui/src/app/skins/shadlike/index.ts create mode 100644 ui/src/app/skins/shadlike/status-bar/status-bar.ts create mode 100644 ui/src/app/skins/shadlike/tab-bar/tab-bar.ts create mode 100644 ui/src/app/skins/shadlike/toasts/toasts.ts create mode 100644 ui/src/app/skins/shadlike/transfer-bar/transfer-bar.html create mode 100644 ui/src/app/skins/shadlike/transfer-bar/transfer-bar.ts diff --git a/ui/src/app/core/platform.ts b/ui/src/app/core/platform.ts index 3a094ef..c648279 100644 --- a/ui/src/app/core/platform.ts +++ b/ui/src/app/core/platform.ts @@ -8,6 +8,9 @@ declare global { __APP_PLATFORM?: string; /** Which root component this window boots. */ __APP_MODE?: 'main' | 'editor' | 'manager'; + /** Which skin tree this window boots. Baked into the preload because + * settings resolve too late to decide layout without a flash. */ + __APP_SKIN?: 'modern' | 'win7'; /** Window-kind specific context (EditorOpenContext for editor windows). */ __APP_CTX?: unknown; } @@ -32,6 +35,26 @@ export const APP_MODE: 'main' | 'editor' | 'manager' = export const EDITOR_CONTEXT = typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined; +/** + * Which skin tree this window booted. Read from the preload rather than from + * SettingsService because both are needed before first paint — main.ts picks a + * root component from it, and it must not change under a running window (the + * native frame is fixed at BrowserWindow construction). + */ +export const APP_SKIN: 'modern' | 'win7' = + typeof window !== 'undefined' ? (window.__APP_SKIN ?? 'modern') : 'modern'; + +export const IS_WIN7_CHROME = APP_SKIN === 'win7'; + +/** + * True when macOS traffic lights overlay the app's own header row — the fused + * `hiddenInset` layout, which reserves `pl-24` for them. False under the win7 + * skin even on macOS: `titleBarStyle: 'hidden'` removes the traffic lights + * altogether, so reserving space would leave a gap. Only the shadlike tree + * reads this; 7like draws its own title bar and never defers to native chrome. + */ +export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME; + /** * Kill the webview's default right-click menu (Reload, Inspect Element…) * everywhere the app doesn't show its own native menu. Text fields keep the diff --git a/ui/src/app/skins/shadlike/app.html b/ui/src/app/skins/shadlike/app.html new file mode 100644 index 0000000..c8e8f62 --- /dev/null +++ b/ui/src/app/skins/shadlike/app.html @@ -0,0 +1,58 @@ +
+ @if (!rpc.available) { +
+
+ +

{{ i18n.t('app.notConnectedTitle') }}

+

+ {{ i18n.t('app.notConnectedBody') }} +

+
+
+ } @else { + + +
+ + +
+ + + + } + + + @if (managerOpen()) { +
+
+ +

{{ i18n.t('app.managerOpen') }}

+ +
+
+ } + + + @if (dialogs.prompt(); as req) { + + } + @if (dialogs.confirm(); as req) { + + } + @if (dialogs.chmod(); as req) { + + } + @if (dialogs.overwrite(); as req) { + + } + + + +
diff --git a/ui/src/app/skins/shadlike/app.ts b/ui/src/app/skins/shadlike/app.ts new file mode 100644 index 0000000..da78b6f --- /dev/null +++ b/ui/src/app/skins/shadlike/app.ts @@ -0,0 +1,231 @@ +import { Component, inject, signal } from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '@core/i18n.service'; +import { BRANDING } from '@shared/branding'; +import type { ColumnKey } from '@shared/types'; +import { ConnectionsService } from '@core/connections.service'; +import { ContextMenu } from './context-menu'; +import { ChmodDialog } from './dialogs/chmod-dialog'; +import { ConfirmDialog } from './dialogs/confirm-dialog'; +import { OverwriteDialog } from './dialogs/overwrite-dialog'; +import { PromptDialog } from './dialogs/prompt-dialog'; +import { DialogsService } from '@core/dialogs.service'; +import { FileActionsService } from '@core/file-actions.service'; +import { FilePanel } from './file-panel/file-panel'; +import { PanelsService } from '@core/panels.service'; +import { blockNativeContextMenu } from '@core/platform'; +import { RpcService } from '@core/rpc.service'; +import { SettingsService } from '@core/settings.service'; +import { StatusBar } from './status-bar/status-bar'; +import { TabBar } from './tab-bar/tab-bar'; +import { ToastService } from '@core/toast.service'; +import { Toasts } from './toasts/toasts'; +import { TransferBar } from './transfer-bar/transfer-bar'; +import { TransfersService } from '@core/transfers.service'; +import { UpdaterService } from '@core/updater.service'; + +@Component({ + selector: 'app-root', + imports: [ + NgIcon, + FilePanel, + TabBar, + StatusBar, + PromptDialog, + ConfirmDialog, + ChmodDialog, + OverwriteDialog, + Toasts, + TransferBar, + ContextMenu, + ], + templateUrl: './app.html', + host: { + '(window:keydown)': 'onWindowKeydown($event)', + '(window:contextmenu)': 'blockContextMenu($event)', + }, +}) +export class App { + readonly i18n = inject(I18nService); + readonly rpc = inject(RpcService); + readonly toast = inject(ToastService); + readonly dialogs = inject(DialogsService); + readonly panels = inject(PanelsService); + readonly actions = inject(FileActionsService); + readonly connections = inject(ConnectionsService); + private readonly transfers = inject(TransfersService); + private readonly settings = inject(SettingsService); + private readonly updater = inject(UpdaterService); + + /** The connection-manager window is open — this window is modal-blocked. */ + readonly managerOpen = signal(false); + + constructor() { + document.title = BRANDING.appName; + if (this.rpc.available) { + this.rpc.onMenuAction((action) => void this.handleMenuAction(action)); + this.rpc.onManagerState((open) => this.managerOpen.set(open)); + void this.promptForEditLeftovers(); + } + } + + focusManager(): void { + void this.rpc.request.openConnectionManager({}); + } + + blockContextMenu(event: Event): void { + blockNativeContextMenu(event); + } + + /** Temp files from external edits in a previous run (crash / quit mid-edit). */ + private async promptForEditLeftovers(): Promise { + const res = await this.rpc.request.getEditLeftovers({}); + if (!res.ok || !res.names.length) return; + const shown = res.names.slice(0, 5).join(', '); + const more = + res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : ''; + this.dialogs.confirm.set({ + title: this.i18n.t('toasts.leftoverTitle'), + body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }), + confirmLabel: this.i18n.t('toasts.leftoverClear'), + destructive: true, + onConfirm: () => void this.rpc.request.clearEditLeftovers({}), + }); + } + + /** A connection by id; reloads once if unseen (a manager-window save can + * race the connectionsChanged push). */ + private async findConnection(id: string) { + const lookup = () => this.connections.connections().find((c) => c.id === id); + if (!lookup()) await this.connections.reload(); + return lookup(); + } + + /** Native application-menu / manager-window actions forwarded from bun. */ + private async handleMenuAction(action: string): Promise { + if (action.startsWith('ctx-')) { + this.handleContextAction(action); + return; + } + if (action.startsWith('connect:')) { + const connection = await this.findConnection(action.slice('connect:'.length)); + if (connection) { + // remote goes right, local stays left — the commander convention + void this.panels.connectPanel('right', connection, connection.name, connection.localDir); + } + return; + } + if (action.startsWith('col:')) { + this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey); + return; + } + if (action.startsWith('open-stack:')) { + const id = action.slice('open-stack:'.length); + let stackConnections = this.connections.connections().filter((c) => c.stackId === id); + if (!stackConnections.length) { + await this.connections.reload(); + stackConnections = this.connections.connections().filter((c) => c.stackId === id); + } + if (stackConnections.length) this.panels.openStack(stackConnections); + return; + } + switch (action) { + case 'new-tab': + this.panels.newWorkspace(); + break; + case 'close-tab': + this.panels.closeWorkspace(this.panels.activeWorkspaceId()); + break; + case 'new-folder': + this.actions.mkdir(this.panels.active()); + break; + case 'new-file': + this.actions.createFile(this.panels.active()); + break; + case 'edit-file': + void this.actions.edit(this.panels.active()); + break; + case 'refresh-active': + void this.panels.active().refresh(); + break; + case 'check-for-updates': + void this.updater.check(); + break; + } + } + + /** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */ + onWindowKeydown(event: KeyboardEvent): void { + if (this.managerOpen()) return; // modal-blocked behind the manager + const mod = event.metaKey || event.ctrlKey; + if (mod && event.key.toLowerCase() === 't') { + event.preventDefault(); + this.panels.newWorkspace(); + } else if (mod && event.key.toLowerCase() === 'w') { + event.preventDefault(); + this.panels.closeWorkspace(this.panels.activeWorkspaceId()); + } else if (event.ctrlKey && event.key === 'Tab') { + event.preventDefault(); + this.panels.cycleWorkspace(event.shiftKey ? -1 : 1); + } else if (mod && !event.shiftKey && !event.altKey) { + // ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like + // Firefox. Matched on event.code so the digit row works on any layout. + const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1]; + if (!digit) return; + event.preventDefault(); + this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1); + } + } + + /** Context-menu actions apply to the row/panel remembered at right-click time. */ + private handleContextAction(action: string): void { + const target = this.actions.menuTarget; + if (!target) return; + const { panel, side, entry } = target; + // the empty-space menu acts on the directory itself + const path = entry?.path ?? panel.cwd(); + switch (action) { + case 'ctx-open': + if (entry) panel.open(entry); + break; + // local shell integration — only bun can reach the OS shell/clipboard + case 'ctx-reveal': + void this.rpc.request.shellItemAction({ action: 'reveal', path }); + break; + case 'ctx-open-default': + void this.rpc.request.shellItemAction({ action: 'open', path }); + break; + case 'ctx-copy-path': + void this.rpc.request.shellItemAction({ action: 'copyPath', path }); + break; + case 'ctx-edit': + void this.actions.edit(panel); + break; + case 'ctx-copy': + void this.transfers.copyToOtherPanel(side); + break; + case 'ctx-rename': + this.actions.rename(panel); + break; + case 'ctx-chmod': + this.actions.chmod(panel); + break; + case 'ctx-delete': + this.actions.delete(panel); + break; + case 'ctx-trash': + if (entry) void this.actions.trash(panel, entry); + break; + case 'ctx-new-folder': + this.actions.mkdir(panel); + break; + case 'ctx-new-file': + this.actions.createFile(panel); + break; + case 'ctx-refresh': + void panel.refresh(); + break; + } + } + +} diff --git a/ui/src/app/skins/shadlike/connection-manager/connection-manager.html b/ui/src/app/skins/shadlike/connection-manager/connection-manager.html new file mode 100644 index 0000000..06e2f69 --- /dev/null +++ b/ui/src/app/skins/shadlike/connection-manager/connection-manager.html @@ -0,0 +1,384 @@ +
+ +
+ +

{{ i18n.t('connMgr.title') }}

+
+ +
+ +
+ +
+
+ + +
+ +
+ + @for (group of store.grouped().stacks; track group.stack.id) { +
+
+ + + {{ group.stack.name }} + +
+ + + {{ group.connections.length }} + + +
+ @if (!group.stack.collapsed) { + @for (connection of group.connections; track connection.id) { + + } @empty { +
{{ i18n.t('connMgr.empty') }}
+ } + } +
+ } + + +
+ @if (store.grouped().stacks.length && store.grouped().loose.length) { +
+ {{ i18n.t('connMgr.connectionsHeader') }} +
+ } + @for (connection of store.grouped().loose; track connection.id) { + + } +
+ + @if (!store.connections().length && !store.stacks().length) { +

+ {{ i18n.t('connMgr.noneTitle') }} +

+

+ {{ i18n.t('connMgr.noneBody') }} +

+ } +
+
+ + +
+ @if (draft(); as d) { +
+ + + +
+ + +
+ + + + +
+ {{ i18n.t('connMgr.auth') }} + + +
+ + @if (d.authKind === 'password') { + + } @else { + + + } + + @if (moreOpen()) { + + } +
+ +
+ + +
+ + +
+ } @else { +
+
+ +

+ {{ i18n.t('connMgr.selectPrompt') }} +

+
+
+ } +
+
+ + + @if (dialogs.prompt(); as req) { + + } + @if (dialogs.confirm(); as req) { + + } + + +
diff --git a/ui/src/app/skins/shadlike/connection-manager/connection-manager.ts b/ui/src/app/skins/shadlike/connection-manager/connection-manager.ts new file mode 100644 index 0000000..c2dd2f8 --- /dev/null +++ b/ui/src/app/skins/shadlike/connection-manager/connection-manager.ts @@ -0,0 +1,271 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '@core/i18n.service'; +import type { SavedConnection, Stack } from '@shared/types'; +import { ConnectionsService } from '@core/connections.service'; +import { ConfirmDialog } from '../dialogs/confirm-dialog'; +import { PromptDialog } from '../dialogs/prompt-dialog'; +import { DialogsService } from '@core/dialogs.service'; +import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform'; +import { RpcService } from '@core/rpc.service'; +import { SettingsService } from '@core/settings.service'; +import { Toasts } from '../toasts/toasts'; + +interface Draft { + id: string; + name: string; + stackId: string | null; + host: string; + port: number; + username: string; + authKind: 'password' | 'key'; + password: string; + privateKeyPath: string; + passphrase: string; + initialPath: string; + localDir: string; +} + +function toDraft(c: SavedConnection): Draft { + return { + id: c.id, + name: c.name, + stackId: c.stackId, + host: c.host, + port: c.port, + username: c.username, + // agent auth only exists on ~/.ssh/config hosts, which never reach this form + authKind: c.auth.kind === 'password' ? 'password' : 'key', + password: c.auth.kind === 'password' ? c.auth.password : '', + privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '', + passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '', + initialPath: c.initialPath ?? '', + localDir: c.localDir ?? '', + }; +} + +function fromDraft(d: Draft): SavedConnection { + return { + id: d.id, + name: d.name.trim() || `${d.username}@${d.host}`, + stackId: d.stackId, + host: d.host.trim(), + port: d.port || 22, + username: d.username.trim(), + auth: + d.authKind === 'password' + ? { kind: 'password', password: d.password } + : { + kind: 'key', + privateKeyPath: d.privateKeyPath.trim(), + ...(d.passphrase ? { passphrase: d.passphrase } : {}), + }, + initialPath: d.initialPath.trim() || null, + localDir: d.localDir.trim() || null, + }; +} + +/** + * Root component of the connection-manager window (__APP_MODE === 'manager'). + * Modal to the main window (enforced bun-side); connect/open-stack requests + * are executed by the MAIN window — this window never touches panels. + */ +@Component({ + selector: 'app-root', + imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts], + templateUrl: './connection-manager.html', + host: { + '(window:keydown.escape)': 'onEscape()', + '(window:contextmenu)': 'blockContextMenu($event)', + }, +}) +export class ConnectionManager { + readonly i18n = inject(I18nService); + readonly store = inject(ConnectionsService); + readonly dialogs = inject(DialogsService); + readonly isMacChrome = IS_MAC_CHROME; + private readonly rpc = inject(RpcService); + // injected for its side effects: fetches settings and applies theme/accent/ + // text-size data attributes to this window's document root + private readonly settings = inject(SettingsService); + + readonly draft = signal(null); + readonly busy = signal(false); + readonly importing = signal(false); + /** The More… section (local directory) in the edit form. */ + readonly moreOpen = signal(false); + /** Connection id being dragged between stacks. */ + readonly draggedId = signal(null); + /** Highlighted drop target: a stack id, 'loose', or null. */ + readonly dropTarget = signal(null); + + readonly valid = computed(() => { + const d = this.draft(); + if (!d) return false; + if (!d.host.trim() || !d.username.trim()) return false; + if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false; + return true; + }); + + patch(key: K, value: Draft[K]): void { + this.draft.update((d) => (d ? { ...d, [key]: value } : d)); + } + + newConnection(): void { + this.moreOpen.set(false); + this.draft.set({ + id: '', + name: '', + stackId: null, + host: '', + port: 22, + username: 'root', + authKind: 'password', + password: '', + privateKeyPath: '', + passphrase: '', + initialPath: '', + localDir: '', + }); + } + + edit(connection: SavedConnection): void { + this.draft.set(toDraft(connection)); + this.moreOpen.set(!!connection.localDir); + } + + close(): void { + void this.rpc.request.managerClose({}); + } + + onEscape(): void { + // in-window dialogs (confirm/prompt) handle their own Escape first + if (!this.dialogs.anyOpen) this.close(); + } + + blockContextMenu(event: Event): void { + blockNativeContextMenu(event); + } + + async save(): Promise { + const d = this.draft(); + if (!d || !this.valid()) return null; + const id = await this.store.upsert(fromDraft(d)); + if (id) this.patch('id', id); + return id; + } + + async saveAndConnect(): Promise { + if (!this.draft()) return; + this.busy.set(true); + try { + const id = await this.save(); + if (!id) return; + // the main window connects (and this window closes); a failed connect + // surfaces as an error there + await this.rpc.request.managerConnect({ id }); + } finally { + this.busy.set(false); + } + } + + deleteCurrent(): void { + const d = this.draft(); + if (!d?.id) { + this.draft.set(null); + return; + } + this.dialogs.confirm.set({ + title: this.i18n.t('connMgr.deleteConnectionTitle'), + body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }), + confirmLabel: this.i18n.t('common.delete'), + destructive: true, + onConfirm: async () => { + await this.store.remove(d.id); + this.draft.set(null); + }, + }); + } + + async pickKeyFile(): Promise { + const res = await this.rpc.request.pickKeyFile({}); + if (res.ok && res.path) this.patch('privateKeyPath', res.path); + } + + async importWinScp(): Promise { + this.importing.set(true); + try { + await this.store.importWinScp(); + } finally { + this.importing.set(false); + } + } + + // ---- stacks ---- + + openStack(stackId: string): void { + // opened as tabs in the main window; bun closes this window after + void this.rpc.request.managerOpenStack({ id: stackId }); + } + + toggleStack(stack: Stack): void { + void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed }); + } + + newStack(): void { + this.dialogs.prompt.set({ + title: this.i18n.t('connMgr.newStackTitle'), + placeholder: this.i18n.t('connMgr.newStackPrompt'), + confirmLabel: this.i18n.t('fileActions.createConfirm'), + onConfirm: (name) => void this.store.upsertStack({ id: '', name }), + }); + } + + renameStack(stack: Stack): void { + this.dialogs.prompt.set({ + title: this.i18n.t('connMgr.renameStackTitle'), + initial: stack.name, + confirmLabel: this.i18n.t('connMgr.renameStack'), + onConfirm: (name) => void this.store.upsertStack({ ...stack, name }), + }); + } + + deleteStack(stack: Stack): void { + this.dialogs.confirm.set({ + title: this.i18n.t('connMgr.deleteStackTitle'), + body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }), + confirmLabel: this.i18n.t('common.delete'), + destructive: true, + onConfirm: () => void this.store.removeStack(stack.id), + }); + } + + // ---- drag & drop (move a connection between stacks / loose) ---- + + onDragStart(event: DragEvent, connection: SavedConnection): void { + this.draggedId.set(connection.id); + event.dataTransfer?.setData('text/plain', connection.id); + if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; + } + + onDragOver(event: DragEvent, target: string): void { + if (!this.draggedId()) return; + event.preventDefault(); + this.dropTarget.set(target); + } + + onDrop(event: DragEvent, stackId: string | null): void { + event.preventDefault(); + const id = this.draggedId(); + this.draggedId.set(null); + this.dropTarget.set(null); + const connection = this.store.connections().find((c) => c.id === id); + if (!connection || connection.stackId === stackId) return; + void this.store.upsert({ ...connection, stackId }); + } + + onDragEnd(): void { + this.draggedId.set(null); + this.dropTarget.set(null); + } +} diff --git a/ui/src/app/skins/shadlike/context-menu.ts b/ui/src/app/skins/shadlike/context-menu.ts new file mode 100644 index 0000000..a61d272 --- /dev/null +++ b/ui/src/app/skins/shadlike/context-menu.ts @@ -0,0 +1,167 @@ +import { + Component, + ElementRef, + computed, + effect, + inject, + signal, + viewChild, +} from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { ContextMenuService, type MenuItem } from '@core/context-menu.service'; +import { FocusService } from '@core/focus.service'; + +/** Rough per-item metrics, used only to decide whether the menu must flip. */ +const ITEM_H = 26; +const SEPARATOR_H = 7; +const MENU_W = 224; // w-56 +const EDGE_GAP = 6; + +/** + * The panel right-click menu. Replaces electrobun's native ContextMenu, which + * is a no-op on Linux — see ContextMenuService for the why. Emits the same + * `ctx-*` / `col:*` action strings the native menu did. + */ +@Component({ + selector: 'app-context-menu', + imports: [NgIcon], + template: ` + @if (menu.request(); as req) { + +
+ + } + `, + // Closing on scroll/blur matches native menu behavior: the anchor row moves + // out from under a menu that would otherwise float over stale content. + host: { + '(window:blur)': 'close()', + '(window:resize)': 'close()', + '(document:scroll)': 'close()', + }, +}) +export class ContextMenu { + readonly menu = inject(ContextMenuService); + private readonly focus = inject(FocusService); + private readonly menuEl = viewChild>('menuEl'); + + readonly activeIndex = signal(-1); + + /** Click point, flipped/clamped so the menu never overflows the viewport. */ + readonly position = computed(() => { + const req = this.menu.request(); + if (!req) return { x: 0, y: 0 }; + const height = + req.items.reduce((sum, i) => sum + (i.type === 'separator' ? SEPARATOR_H : ITEM_H), 0) + 8; + const x = + req.x + MENU_W > window.innerWidth - EDGE_GAP + ? Math.max(EDGE_GAP, req.x - MENU_W) + : req.x; + const y = + req.y + height > window.innerHeight - EDGE_GAP + ? Math.max(EDGE_GAP, req.y - height) + : req.y; + return { x, y }; + }); + + constructor() { + // Focus the menu once it exists so arrow keys / Escape reach it. Keyed on + // the viewChild signal, NOT on request(): under zoneless CD an effect can + // run before the @if has rendered the element. + effect(() => this.menuEl()?.nativeElement.focus()); + } + + close(): void { + if (!this.menu.request()) return; + this.menu.close(); + this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted + this.focus.bump(); + } + + pick(item: MenuItem): void { + if (!item.action) return; + const action = item.action; + this.close(); + this.menu.onPick(action); + } + + /** Right-clicking the backdrop closes rather than stacking a second menu. */ + onBackdropContext(event: MouseEvent): void { + event.preventDefault(); + this.close(); + } + + onKeydown(event: KeyboardEvent): void { + const items = this.menu.request()?.items ?? []; + switch (event.key) { + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.close(); + break; + case 'ArrowDown': + event.preventDefault(); + this.step(items, 1); + break; + case 'ArrowUp': + event.preventDefault(); + this.step(items, -1); + break; + case 'Enter': + case ' ': { + event.preventDefault(); + const item = items[this.activeIndex()]; + if (item) this.pick(item); + break; + } + } + } + + /** Move the highlight by one selectable item, skipping separators, wrapping. */ + private step(items: MenuItem[], delta: number): void { + const count = items.length; + if (!count) return; + let index = this.activeIndex(); + for (let hops = 0; hops < count; hops++) { + index = (index + delta + count) % count; + if (items[index].type !== 'separator') { + this.activeIndex.set(index); + return; + } + } + } +} diff --git a/ui/src/app/skins/shadlike/dialogs/chmod-dialog.ts b/ui/src/app/skins/shadlike/dialogs/chmod-dialog.ts new file mode 100644 index 0000000..40b7f5d --- /dev/null +++ b/ui/src/app/skins/shadlike/dialogs/chmod-dialog.ts @@ -0,0 +1,129 @@ +import { Component, computed, inject, input, signal } from '@angular/core'; +import { I18nService } from '@core/i18n.service'; +import { DialogsService, type ChmodRequest } from '@core/dialogs.service'; +import { FocusService } from '@core/focus.service'; + +const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const; +const PERMS = ['r', 'w', 'x'] as const; + +@Component({ + selector: 'app-chmod-dialog', + template: ` +
+
+

{{ i18n.t('dialogs.chmodTitle') }}

+

{{ targetLabel() }}

+ + + + + + + + + + + + @for (role of roles; track role; let r = $index) { + + + @for (perm of perms; track perm; let p = $index) { + + } + + } + +
{{ i18n.t('dialogs.read') }}{{ i18n.t('dialogs.write') }}{{ i18n.t('dialogs.execute') }}
{{ i18n.t('dialogs.' + role) }} + +
+ +
+ + +
+ +
+ + +
+
+
+ `, +}) +export class ChmodDialog { + readonly i18n = inject(I18nService); + readonly request = input.required(); + + readonly roles = ROLES; + readonly perms = PERMS; + + private readonly dialogs = inject(DialogsService); + private readonly focus = inject(FocusService); + + // mode is initialized lazily from the request on first read + private readonly modeOverride = signal(null); + readonly mode = computed(() => this.modeOverride() ?? this.request().initialMode & 0o777); + readonly octal = computed(() => this.mode().toString(8).padStart(3, '0')); + + targetLabel(): string { + const targets = this.request().targets; + return targets.length === 1 + ? targets[0] + : this.i18n.t('dialogs.chmodItems', { count: targets.length }); + } + + hasBit(role: number, perm: number): boolean { + return !!(this.mode() & (0o400 >> (role * 3 + perm))); + } + + toggleBit(role: number, perm: number): void { + this.modeOverride.set(this.mode() ^ (0o400 >> (role * 3 + perm))); + } + + setOctal(text: string): void { + if (/^[0-7]{1,4}$/.test(text)) { + this.modeOverride.set(parseInt(text, 8) & 0o777); + } + } + + submit(): void { + const req = this.request(); + const mode = this.mode(); + this.close(); + req.onConfirm(mode); + } + + cancel(): void { + this.close(); + } + + private close(): void { + this.dialogs.chmod.set(null); + this.focus.bump(); + } +} diff --git a/ui/src/app/skins/shadlike/dialogs/confirm-dialog.ts b/ui/src/app/skins/shadlike/dialogs/confirm-dialog.ts new file mode 100644 index 0000000..84a6f6f --- /dev/null +++ b/ui/src/app/skins/shadlike/dialogs/confirm-dialog.ts @@ -0,0 +1,66 @@ +import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core'; +import { I18nService } from '@core/i18n.service'; +import { DialogsService, type ConfirmRequest } from '@core/dialogs.service'; +import { FocusService } from '@core/focus.service'; + +@Component({ + selector: 'app-confirm-dialog', + template: ` +
+
+

{{ request().title }}

+

{{ request().body }}

+
+ + +
+
+
+ `, +}) +export class ConfirmDialog { + readonly i18n = inject(I18nService); + readonly request = input.required(); + + private readonly confirmBtn = viewChild.required>('confirmBtn'); + private readonly dialogs = inject(DialogsService); + private readonly focus = inject(FocusService); + + constructor() { + afterNextRender(() => this.confirmBtn().nativeElement.focus()); + } + + submit(): void { + const req = this.request(); + this.close(); + req.onConfirm(); + } + + cancel(): void { + this.close(); + } + + private close(): void { + this.dialogs.confirm.set(null); + this.focus.bump(); + } +} diff --git a/ui/src/app/skins/shadlike/dialogs/overwrite-dialog.ts b/ui/src/app/skins/shadlike/dialogs/overwrite-dialog.ts new file mode 100644 index 0000000..12a930a --- /dev/null +++ b/ui/src/app/skins/shadlike/dialogs/overwrite-dialog.ts @@ -0,0 +1,149 @@ +import { + Component, + ElementRef, + afterNextRender, + computed, + inject, + input, + viewChild, +} from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '@core/i18n.service'; +import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service'; +import { FocusService } from '@core/focus.service'; + +@Component({ + selector: 'app-overwrite-dialog', + imports: [NgIcon], + template: ` +
+
+
+

+ {{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }} + "{{ request().srcEntry.name }}"? +

+ @if (request().total > 1) { + + {{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }} + + } +
+

+ @if (isDir()) { + {{ i18n.t('dialogs.overwriteDirBody') }} + } @else { + {{ i18n.t('dialogs.overwriteFileBody') }} + } +

+ + +
+ @for (side of comparison(); track side.label) { +
+
+ + {{ i18n.t('dialogs.' + side.label) }} + + @if (side.newer) { + + {{ i18n.t('dialogs.newer') }} + + } +
+
+ + + {{ isDir() ? '—' : i18n.size(side.entry.size) }} + +
+
+ {{ i18n.date(side.entry.mtime) }} +
+
+ } +
+ + +
+ +
+ @if (request().total > 1) { + + +
+ } + + +
+
+
+ `, +}) +export class OverwriteDialog { + readonly i18n = inject(I18nService); + readonly request = input.required(); + + + private readonly dialogs = inject(DialogsService); + private readonly focus = inject(FocusService); + private readonly primaryBtn = viewChild.required>('primaryBtn'); + + constructor() { + afterNextRender(() => this.primaryBtn().nativeElement.focus()); + } + + readonly isDir = computed(() => this.request().srcEntry.type === 'dir'); + + readonly comparison = computed(() => { + const { srcEntry, dstEntry } = this.request(); + return [ + { label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime }, + { label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime }, + ]; + }); + + pick(choice: OverwriteChoice): void { + const req = this.request(); + this.dialogs.overwrite.set(null); + this.focus.bump(); + req.resolve(choice); + } +} diff --git a/ui/src/app/skins/shadlike/dialogs/prompt-dialog.ts b/ui/src/app/skins/shadlike/dialogs/prompt-dialog.ts new file mode 100644 index 0000000..8f35683 --- /dev/null +++ b/ui/src/app/skins/shadlike/dialogs/prompt-dialog.ts @@ -0,0 +1,86 @@ +import { + Component, + ElementRef, + afterNextRender, + inject, + input, + signal, + viewChild, +} from '@angular/core'; +import { I18nService } from '@core/i18n.service'; +import { DialogsService, type PromptRequest } from '@core/dialogs.service'; +import { FocusService } from '@core/focus.service'; + +@Component({ + selector: 'app-prompt-dialog', + template: ` +
+
+

{{ request().title }}

+ +
+ + +
+
+
+ `, +}) +export class PromptDialog { + readonly i18n = inject(I18nService); + readonly request = input.required(); + readonly value = signal(''); + + private readonly box = viewChild.required>('box'); + private readonly dialogs = inject(DialogsService); + private readonly focus = inject(FocusService); + + constructor() { + afterNextRender(() => { + this.value.set(this.request().initial ?? ''); + const el = this.box().nativeElement; + el.focus(); + el.select(); + }); + } + + submit(): void { + const value = this.value().trim(); + if (!value) return; + const req = this.request(); + this.close(); + req.onConfirm(value); + } + + cancel(): void { + this.close(); + } + + private close(): void { + this.dialogs.prompt.set(null); + this.focus.bump(); + } +} diff --git a/ui/src/app/skins/shadlike/editor-window/editor-window.html b/ui/src/app/skins/shadlike/editor-window/editor-window.html new file mode 100644 index 0000000..4fca822 --- /dev/null +++ b/ui/src/app/skins/shadlike/editor-window/editor-window.html @@ -0,0 +1,209 @@ +
+ @if (!ctx) { +
+
+ +

{{ i18n.t('editor.noFileTitle') }}

+

+ {{ i18n.t('editor.noFileBody') }} +

+
+
+ } @else { + +
+ +

+ {{ ctx.name }}{{ dirty() ? ' •' : '' }} +

+ {{ ctx.sessionTitle }} +
+ + +
+ + @if (findOpen()) { +
+ + + + {{ countLabel() }} + + + +
+ +
+ } + + @if (sessionDead()) { +
+ + {{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }} +
+ } + + @if (loading()) { +
+ +
+ } @else if (loadError(); as error) { +
+
+ +

{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}

+

{{ error }}

+ +
+
+ } @else { +
+ + @if (!wrap()) { + + } + +
+ +
+ {{ i18n.t('editor.shortcuts', { mod: modLabel }) }} + · + + {{ i18n.t('editor.lines', { count: lineCount() }) }} + +
+ + +
+ } + + + @if (closePrompt()) { +
+
+

{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}

+

+ {{ i18n.t('editor.saveChangesBody') }} +

+
+ +
+ + +
+
+
+ } + } + + +
diff --git a/ui/src/app/skins/shadlike/editor-window/editor-window.ts b/ui/src/app/skins/shadlike/editor-window/editor-window.ts new file mode 100644 index 0000000..0a45605 --- /dev/null +++ b/ui/src/app/skins/shadlike/editor-window/editor-window.ts @@ -0,0 +1,348 @@ +import { + Component, + DOCUMENT, + ElementRef, + afterNextRender, + computed, + effect, + inject, + signal, + viewChild, +} from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '@core/i18n.service'; +import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform'; +import { RpcService } from '@core/rpc.service'; +import { SettingsService } from '@core/settings.service'; +import { ToastService } from '@core/toast.service'; +import { Toasts } from '../toasts/toasts'; + +/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */ +const DRAFT_DEBOUNCE_MS = 400; + +/** + * Root component of an editor window (window.__APP_MODE === 'editor'). + * + * Deliberately injects only Rpc/Toast/Settings: main-window root services + * (PanelsService, ConnectionsService, TransfersService — and the dialogs that + * inject them) have constructor side effects like creating workspaces and + * loading connections that must not run here. + */ +@Component({ + selector: 'app-root', + imports: [NgIcon, Toasts], + templateUrl: './editor-window.html', + host: { + '(window:keydown)': 'onKeydown($event)', + '(window:blur)': 'flushDraft()', + '(window:contextmenu)': 'blockContextMenu($event)', + }, +}) +export class EditorWindow { + readonly i18n = inject(I18nService); + readonly ctx = EDITOR_CONTEXT; + readonly isMacChrome = IS_MAC_CHROME; + readonly modLabel = MOD_LABEL; + readonly altLabel = ALT_LABEL; + + readonly loading = signal(true); + readonly loadError = signal(null); + readonly text = signal(''); + readonly savedContent = signal(''); + readonly dirty = computed(() => this.text() !== this.savedContent()); + readonly saving = signal(false); + readonly closePrompt = signal(false); + readonly sessionDead = signal(false); + /** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers + * would need per-edit height measurement — not worth it without an editor lib. */ + readonly wrap = signal(false); + readonly findOpen = signal(false); + readonly findQuery = signal(''); + readonly activeMatch = signal(0); + + /** Line count without allocating a split array on every keystroke. */ + readonly lineCount = computed(() => { + const t = this.text(); + let n = 1; + let i = -1; + while ((i = t.indexOf('\n', i + 1)) !== -1) n++; + return n; + }); + readonly gutterText = computed(() => + Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'), + ); + readonly gutterDigits = computed(() => String(this.lineCount()).length); + + /** Case-insensitive match start offsets for the find query. */ + readonly matches = computed(() => { + const query = this.findQuery().toLowerCase(); + if (!query) return []; + const hay = this.text().toLowerCase(); + const out: number[] = []; + let i = hay.indexOf(query); + while (i !== -1) { + out.push(i); + i = hay.indexOf(query, i + query.length); + } + return out; + }); + readonly countLabel = computed(() => + this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0', + ); + + private readonly box = viewChild>('box'); + private readonly gutter = viewChild>('gutter'); + private readonly findBox = viewChild>('findBox'); + private cachedCharWidth = 0; + private readonly rpc = inject(RpcService); + private readonly toast = inject(ToastService); + private readonly document = inject(DOCUMENT); + // injected for its side effects: fetches settings and applies theme/accent/ + // text-size data attributes to this window's document root + private readonly settings = inject(SettingsService); + + private draftTimer: ReturnType | null = null; + private lastSentDirty = false; + + constructor() { + const ctx = this.ctx; + if (!ctx) return; // opened outside the app (plain browser) — error card renders + + const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`; + effect(() => { + this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`; + }); + + this.rpc.onEditorCloseRequest(() => this.requestClose()); + this.rpc.onSessionClosed((event) => { + if (event.sessionId === ctx.sessionId) this.sessionDead.set(true); + }); + + afterNextRender(() => void this.load(ctx.sessionId, ctx.path)); + } + + private async load(sessionId: string, path: string): Promise { + const res = await this.rpc.request.readFile({ sessionId, path }); + if (!res.ok) { + this.loadError.set(res.error); + this.loading.set(false); + return; + } + this.savedContent.set(res.content); + this.text.set(res.content); + this.loading.set(false); + queueMicrotask(() => this.box()?.nativeElement.focus()); + } + + blockContextMenu(event: Event): void { + blockNativeContextMenu(event); + } + + onInput(value: string): void { + this.text.set(value); + this.scheduleDraft(); + } + + /** Keep the line-number gutter locked to the textarea's scroll position. */ + onScroll(): void { + const gutter = this.gutter()?.nativeElement; + if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop; + } + + toggleWrap(): void { + this.wrap.update((w) => !w); + queueMicrotask(() => this.onScroll()); + } + + // ---- find ---- + + openFind(): void { + const el = this.box()?.nativeElement; + // prefill from the current selection when it's a sane single-line snippet + if (el && el.selectionStart !== el.selectionEnd) { + const selected = el.value.slice(el.selectionStart, el.selectionEnd); + if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected); + } + this.findOpen.set(true); + this.activeMatch.set(0); + queueMicrotask(() => { + const input = this.findBox()?.nativeElement; + input?.focus(); + input?.select(); + }); + } + + closeFind(): void { + this.findOpen.set(false); + // selection was left on the match — the caret lands there + this.box()?.nativeElement.focus(); + } + + onFindInput(value: string): void { + this.findQuery.set(value); + const list = this.matches(); + if (!list.length) return; + // jump to the first match at/after the caret, else wrap to the first + const from = this.box()?.nativeElement.selectionStart ?? 0; + const at = list.findIndex((start) => start >= from); + this.goTo(at >= 0 ? at : 0); + } + + findStep(delta: 1 | -1): void { + const count = this.matches().length; + if (!count) return; + this.goTo((this.activeMatch() + delta + count) % count); + } + + onFindKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter') { + event.preventDefault(); + this.findStep(event.shiftKey ? -1 : 1); + } else if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.closeFind(); + } + } + + /** Select match `index` and scroll it into the middle of the viewport. */ + private goTo(index: number): void { + const el = this.box()?.nativeElement; + const start = this.matches()[index]; + if (!el || start === undefined) return; + this.activeMatch.set(index); + el.setSelectionRange(start, start + this.findQuery().length); + + const text = this.text(); + const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20; + let line = 0; + let i = text.indexOf('\n'); + while (i !== -1 && i < start) { + line++; + i = text.indexOf('\n', i + 1); + } + el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2); + if (!this.wrap()) { + const column = start - (text.lastIndexOf('\n', start - 1) + 1); + el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2); + } + this.onScroll(); + } + + /** Monospace advance width, measured once per window. */ + private charWidth(el: HTMLTextAreaElement): number { + if (!this.cachedCharWidth) { + const canvas = this.document.createElement('canvas').getContext('2d')!; + canvas.font = getComputedStyle(el).font; + this.cachedCharWidth = canvas.measureText('0').width || 7; + } + return this.cachedCharWidth; + } + + // ---- draft mirror: bun holds the latest content for the native-close prompt ---- + + private scheduleDraft(): void { + if (this.dirty() !== this.lastSentDirty) { + // dirty flag flipped — tell bun immediately so the native title updates + this.flushDraft(); + return; + } + if (this.draftTimer) clearTimeout(this.draftTimer); + this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS); + } + + flushDraft(): void { + if (!this.ctx || this.loading()) return; + if (this.draftTimer) { + clearTimeout(this.draftTimer); + this.draftTimer = null; + } + this.lastSentDirty = this.dirty(); + this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() }); + } + + // ---- save / close ---- + + async save(): Promise { + if (!this.dirty() || this.saving()) return true; + const content = this.text(); + this.saving.set(true); + try { + const res = await this.rpc.request.editorSave({ content }); + if (!res.ok) { + this.toast.error(res.error); + return false; + } + this.savedContent.set(content); + this.lastSentDirty = false; // bun record already cleaned by the handler + this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name })); + return true; + } finally { + this.saving.set(false); + } + } + + /** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */ + requestClose(): void { + if (this.dirty()) { + this.closePrompt.set(true); + } else { + void this.rpc.request.editorClose({}); + } + } + + async saveAndClose(): Promise { + if (await this.save()) void this.rpc.request.editorClose({}); + } + + discardAndClose(): void { + void this.rpc.request.editorClose({}); + } + + cancelClose(): void { + this.closePrompt.set(false); + queueMicrotask(() => this.box()?.nativeElement.focus()); + } + + // ---- keyboard ---- + + onKeydown(event: KeyboardEvent): void { + const mod = event.metaKey || event.ctrlKey; + if (mod && event.key.toLowerCase() === 's') { + event.preventDefault(); + void this.save(); + } else if (mod && event.key.toLowerCase() === 'f') { + event.preventDefault(); + this.openFind(); + } else if (mod && event.key.toLowerCase() === 'w') { + // reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and + // bun routes it back as editorCloseRequest + event.preventDefault(); + this.requestClose(); + } else if (event.altKey && event.code === 'KeyZ') { + event.preventDefault(); + this.toggleWrap(); + } else if (event.key === 'Escape' && this.closePrompt()) { + event.preventDefault(); + this.cancelClose(); + } else if (event.key === 'Escape' && this.findOpen()) { + event.preventDefault(); + this.closeFind(); + } else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) { + // keep Tab as an input character inside the editor + event.preventDefault(); + this.insertTab(); + } + } + + private insertTab(): void { + const el = this.box()!.nativeElement; + // execCommand keeps the native undo stack intact where supported + if (!this.document.execCommand('insertText', false, '\t')) { + const { selectionStart, selectionEnd, value } = el; + el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`; + el.selectionStart = el.selectionEnd = selectionStart + 1; + } + this.onInput(el.value); + } +} diff --git a/ui/src/app/skins/shadlike/file-panel/file-panel.html b/ui/src/app/skins/shadlike/file-panel/file-panel.html new file mode 100644 index 0000000..33a4c9b --- /dev/null +++ b/ui/src/app/skins/shadlike/file-panel/file-panel.html @@ -0,0 +1,327 @@ + +
+ +
+ + + + + + + + @if (chmodAvailable()) { + + } + + + @if (menuOpen()) { +
+
+ + + + @for (drive of drives(); track drive) { + + } + + @if (connections.grouped().loose.length || connections.grouped().stacks.length) { +
+ } + @for (connection of connections.grouped().loose; track connection.id) { + + } + @for (group of connections.grouped().stacks; track group.stack.id) { +
+ {{ group.stack.name }} +
+ @for (connection of group.connections; track connection.id) { + + } + } + + @if (connections.sshHosts().length) { +
+
+ ~/.ssh/config +
+ @for (host of connections.sshHosts(); track host.name) { + + } + } + +
+ } +
+ + +
+ + + +
+ + +
+ + @for (col of visibleColumns(); track col.key) { + + } +
+ + +@if (panel().filter(); as query) { +
+ + {{ query }} + + {{ i18n.t('panel.matches', { count: filterMatches() }) }} + + +
+} + + +
+
+ @if (panel().connecting()) { +
+
+ + + {{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }} + + +
+
+ } @else { + @for (entry of panel().sorted(); track entry.name; let i = $index) { +
+
+ + {{ entry.name }} +
+ @for (col of visibleColumns(); track col.key) { + @switch (col.key) { + @case ('size') { + + {{ entry.type === 'dir' ? '—' : formatSize(entry.size) }} + + } + @case ('mtime') { + + {{ formatDate(entry.mtime) }} + + } + @case ('kind') { + + {{ fileKind(entry) }} + + + + } + @case ('rights') { + + {{ entry.name === '..' ? '' : formatMode(entry.mode) }} + + } + @case ('owner') { + + {{ entry.name === '..' ? '' : (entry.owner ?? '—') }} + + } + @case ('group') { + + {{ entry.name === '..' ? '' : (entry.group ?? '—') }} + + } + } + } +
+ } @empty { +
{{ i18n.t('panel.emptyDir') }}
+ } + @if (panel().filter() && filterMatches() === 0) { +
{{ i18n.t('panel.noMatches') }}
+ } + } +
+@if (panel().slowLoad() && !panel().connecting()) { +
+ +
+} +
diff --git a/ui/src/app/skins/shadlike/file-panel/file-panel.ts b/ui/src/app/skins/shadlike/file-panel/file-panel.ts new file mode 100644 index 0000000..bb8391d --- /dev/null +++ b/ui/src/app/skins/shadlike/file-panel/file-panel.ts @@ -0,0 +1,361 @@ +import { + Component, + ElementRef, + computed, + effect, + inject, + input, + signal, + viewChild, +} from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '@core/i18n.service'; +import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types'; +import { ConnectionsService } from '@core/connections.service'; +import { ContextMenuService } from '@core/context-menu.service'; +import { DialogsService } from '@core/dialogs.service'; +import { FileActionsService } from '@core/file-actions.service'; +import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons'; +import { formatMode } from '@core/format'; +import { PanelsService, type Side, type SortKey } from '@core/panels.service'; +import { IS_WINDOWS } from '@core/platform'; +import { RpcService } from '@core/rpc.service'; +import { SettingsService } from '@core/settings.service'; +import { TransfersService } from '@core/transfers.service'; + +/** Optional columns in fixed display order, with their track widths. */ +const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [ + { key: 'size', label: 'Size', width: '5.5rem' }, + { key: 'mtime', label: 'Modified', width: '8.5rem' }, + { key: 'kind', label: 'Kind', width: '5.5rem' }, + { key: 'rights', label: 'Rights', width: '6.5rem' }, + { key: 'owner', label: 'Owner', width: '6rem' }, + { key: 'group', label: 'Group', width: '6rem' }, +]; + +@Component({ + selector: 'app-file-panel', + imports: [NgIcon], + templateUrl: './file-panel.html', + host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' }, +}) +export class FilePanel { + readonly i18n = inject(I18nService); + readonly side = input.required(); + + readonly panels = inject(PanelsService); + readonly actions = inject(FileActionsService); + readonly transfers = inject(TransfersService); + readonly connections = inject(ConnectionsService); + readonly dialogs = inject(DialogsService); + private readonly menu = inject(ContextMenuService); + private readonly settings = inject(SettingsService); + private readonly rpc = inject(RpcService); + 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')); + + readonly pathDraft = signal(''); + readonly menuOpen = signal(false); + /** Windows drive roots for the source picker; always empty elsewhere. */ + readonly drives = signal([]); + /** chmod on Windows local files only flips the read-only bit — hide it. */ + readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote())); + + private readonly listEl = viewChild.required>('list'); + private lastClickedIndex = 0; + + // locale-reactive wrappers: reading i18n.locale() inside makes the file-list + // cells re-render on a language switch (see i18n.service reactive t()) + readonly formatSize = (bytes: number): string => this.i18n.size(bytes); + readonly formatDate = (ms: number): string => this.i18n.date(ms); + readonly formatMode = formatMode; + readonly fileKind = (entry: FileEntry): string => { + const key = fileKindKey(entry); + return key ? this.i18n.t('kinds.' + key) : ''; + }; + + /** The optional columns currently shown, in canonical order. */ + readonly visibleColumns = computed(() => { + const visible = this.settings.columns(); + return COLUMN_DEFS.filter((c) => visible.includes(c.key)); + }); + readonly gridTemplate = computed(() => + ['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '), + ); + + headerContextMenu(event: MouseEvent): void { + event.preventDefault(); + this.menu.open(event, this.menu.columnItems(this.settings.columns())); + } + + pickDrive(drive: string): void { + this.menuOpen.set(false); + this.activate(); + void this.panel().load(drive); + } + + constructor() { + if (IS_WINDOWS && this.rpc.available) { + void this.rpc.request.listDrives({}).then((res) => { + if (res.ok) this.drives.set(res.drives); + }); + } + // keep the editable path bar in sync with the actual cwd + effect(() => this.pathDraft.set(this.panel().cwd())); + // focus follows the active side (Tab switches panels, dialogs refocus on close) + effect(() => { + this.panels.focusTick(); + if (this.isActive()) this.listEl().nativeElement.focus(); + }); + } + + activate(): void { + 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()); + } + + pickLocal(): void { + this.menuOpen.set(false); + void this.panels.disconnectPanel(this.side()); + } + + pickConnection(connection: SavedConnection): void { + this.menuOpen.set(false); + this.activate(); + void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir); + } + + toggleMenu(): void { + this.menuOpen.set(!this.menuOpen()); + // re-read ~/.ssh/config on each open so external edits show up + if (this.menuOpen()) void this.connections.reloadSshHosts(); + } + + pickSshHost(host: SshConfigHost): void { + this.menuOpen.set(false); + this.activate(); + void this.panels.connectPanel( + this.side(), + { + host: host.hostname, + port: host.port, + username: host.username, + auth: host.identityFile + ? { kind: 'key', privateKeyPath: host.identityFile } + : { kind: 'agent' }, + initialPath: null, + }, + host.name, + ); + } + + sshHostTitle(host: SshConfigHost): string { + if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported'; + return `${host.username}@${host.hostname}:${host.port}`; + } + + iconFor(entry: FileEntry): string { + return fileGlyph(entry, this.settings.iconColors() === 'multi'); + } + + iconClass(entry: FileEntry): string { + return fileIconClass(entry, this.settings.iconColors() === 'multi'); + } + + isSelected(entry: FileEntry): boolean { + return this.panel().selected().has(entry.name); + } + + clearFilter(): void { + this.panel().setFilter(''); + this.listEl().nativeElement.focus(); + } + + filterMatches(): number { + return this.panel() + .sorted() + .filter((e) => e.name !== '..').length; + } + + sortIndicator(key: SortKey): string { + if (this.panel().sortKey() !== key) return ''; + return this.panel().sortAsc() ? '↑' : '↓'; + } + + commitPath(): void { + if (this.blocked()) return; + const value = this.pathDraft().trim(); + if (value) void this.panel().load(value); + this.listEl().nativeElement.focus(); + } + + resetPath(): void { + this.pathDraft.set(this.panel().cwd()); + this.listEl().nativeElement.focus(); + } + + rowClick(event: MouseEvent, index: number): void { + if (this.blocked()) return; + this.activate(); + const panel = this.panel(); + if (event.shiftKey) { + panel.selectRange(this.lastClickedIndex, index); + } else if (event.metaKey || event.ctrlKey) { + const entry = panel.sorted()[index]; + if (entry) panel.toggleSelect(entry.name); + } else { + panel.clearSelection(); + } + panel.cursor.set(index); + this.lastClickedIndex = index; + } + + 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; + // standard behavior: keep the selection when right-clicking inside it + if (entry && !panel.selected().has(entry.name)) panel.clearSelection(); + panel.cursor.set(index); + this.actions.openContextMenu(event, { side: this.side(), panel, entry }); + } + + listContextMenu(event: MouseEvent): void { + event.preventDefault(); + if (this.blocked()) return; + this.activate(); + this.actions.openContextMenu(event, { 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; + + if (key === 'Tab') { + event.preventDefault(); + this.panels.setActiveSide(this.panels.otherSide(this.side())); + return; + } + if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') { + event.preventDefault(); + panel.selectAll(); + return; + } + + // type-to-filter: printable characters build the query (Space only once a + // query exists — an empty-filter Space keeps its toggle-select meaning) + if ( + key.length === 1 && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + (key !== ' ' || panel.filter() !== '') + ) { + event.preventDefault(); + panel.setFilter(panel.filter() + key); + this.scrollCursorIntoView(); + return; + } + if (key === 'Backspace' && panel.filter() !== '') { + event.preventDefault(); + panel.setFilter(panel.filter().slice(0, -1)); + return; + } + if (key === 'Escape' && panel.filter() !== '') { + event.preventDefault(); + panel.setFilter(''); + return; + } + + switch (key) { + case 'ArrowDown': + panel.moveCursor(1); + break; + case 'ArrowUp': + panel.moveCursor(-1); + break; + case 'PageDown': + panel.moveCursor(20); + break; + case 'PageUp': + panel.moveCursor(-20); + break; + case 'Home': + panel.cursor.set(0); + break; + case 'End': + panel.cursor.set(Math.max(0, panel.sorted().length - 1)); + break; + case ' ': + { + const entry = panel.sorted()[panel.cursor()]; + if (entry) panel.toggleSelect(entry.name); + panel.moveCursor(1); + } + break; + case 'Enter': + this.openAt(panel.cursor()); + return void event.preventDefault(); + case 'Backspace': + panel.up(); + break; + case 'F2': + this.actions.rename(panel); + break; + case 'F4': + void this.actions.edit(panel); + break; + case 'F5': + void this.transfers.copyToOtherPanel(this.side()); + break; + case 'F7': + this.actions.mkdir(panel); + break; + case 'F8': + case 'Delete': + this.actions.delete(panel); + break; + default: + return; // let unhandled keys through + } + event.preventDefault(); + this.scrollCursorIntoView(); + } + + private scrollCursorIntoView(): void { + const row = this.listEl().nativeElement.querySelector( + `[data-index="${this.panel().cursor()}"]`, + ); + row?.scrollIntoView({ block: 'nearest' }); + } +} diff --git a/ui/src/app/skins/shadlike/index.ts b/ui/src/app/skins/shadlike/index.ts new file mode 100644 index 0000000..1ad1219 --- /dev/null +++ b/ui/src/app/skins/shadlike/index.ts @@ -0,0 +1,17 @@ +import { App } from './app'; +import { ConnectionManager } from './connection-manager/connection-manager'; +import { EditorWindow } from './editor-window/editor-window'; + +/** + * The three window roots of the modern (shadcn-style) skin, keyed by APP_MODE. + * + * Same shape as SEVEN_ROOTS, which is the point: main.ts picks a tree by skin + * and then a root by mode, without knowing anything about either skin. This + * skin has no title-bar component — it defers to the native window frame + * (hiddenInset on macOS), which is exactly what IS_MAC_CHROME accounts for. + */ +export const SHAD_ROOTS = { + main: App, + manager: ConnectionManager, + editor: EditorWindow, +} as const; diff --git a/ui/src/app/skins/shadlike/status-bar/status-bar.ts b/ui/src/app/skins/shadlike/status-bar/status-bar.ts new file mode 100644 index 0000000..b409547 --- /dev/null +++ b/ui/src/app/skins/shadlike/status-bar/status-bar.ts @@ -0,0 +1,78 @@ +import { Component, computed, inject } from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '@core/i18n.service'; +import type { TransferSnapshot } from '@shared/types'; +import { formatSpeed } from '@core/format'; +import { PanelsService } from '@core/panels.service'; +import { SettingsService } from '@core/settings.service'; +import { TransfersService } from '@core/transfers.service'; + +@Component({ + selector: 'app-status-bar', + imports: [NgIcon], + template: ` +
+ + + + +
+ + + {{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }} + + · {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }} + @if (activePanel().selected().size; as count) { + · {{ i18n.t('transfer.selected', { count }) }} + } +
+
+ `, +}) +export class StatusBar { + readonly i18n = inject(I18nService); + readonly transfers = inject(TransfersService); + readonly panels = inject(PanelsService); + readonly settings = inject(SettingsService); + readonly activePanel = computed(() => this.panels.get(this.panels.activeSide())); + + percent(job: TransferSnapshot): number { + if (!job.totalBytes) return 0; + return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100)); + } + + speedSuffix(job: TransferSnapshot): string { + const speed = this.transfers.speeds().get(job.id); + return speed ? ` · ${formatSpeed(speed, this.settings.speedUnit())}` : ''; + } +} diff --git a/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts new file mode 100644 index 0000000..5a20c0f --- /dev/null +++ b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts @@ -0,0 +1,540 @@ +import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { BRANDING } from '@shared/branding'; +import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n'; +import type { AccentTone } from '@shared/types'; +import { I18nService } from '@core/i18n.service'; +import { PanelsService } from '@core/panels.service'; +import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform'; +import { RpcService } from '@core/rpc.service'; +import { SettingsService } from '@core/settings.service'; +import { UpdaterService } from '@core/updater.service'; + +const THEMES = ['system', 'light', 'dark'] as const; +const DARK_TONES = ['dark', 'darker'] as const; +// Swatch colors are fixed mid-tones so every dot reads on both light and dark +// cards (the live --primary values would vanish against one of them). Names are +// translated via options.accentNames.. +const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [ + { value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' }, + { value: 'blue', swatch: 'oklch(0.65 0.17 255)' }, + { value: 'indigo', swatch: 'oklch(0.65 0.19 277)' }, + { value: 'purple', swatch: 'oklch(0.65 0.17 305)' }, + { value: 'rose', swatch: 'oklch(0.65 0.17 8)' }, + { value: 'orange', swatch: 'oklch(0.68 0.17 50)' }, + { value: 'green', swatch: 'oklch(0.65 0.15 150)' }, + { value: 'teal', swatch: 'oklch(0.65 0.12 185)' }, +]; +const SPEED_UNITS = [ + { value: 'MBps', label: 'MB/s' }, + { value: 'Mbps', label: 'Mbps' }, +] as const; +const TEXT_SIZES = ['compact', 'default', 'large'] as const; +const ICON_COLOR_MODES = ['plain', 'multi'] as const; +const TRANSFER_MODES = ['single', 'multi'] as const; +const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; + +@Component({ + selector: 'app-tab-bar', + imports: [NgIcon], + template: ` + +
+
+ @for (ws of panels.workspaces(); track ws.id; let i = $index) { +
+ + @if (panels.workspaces().length > 1) { + + } @else { + + } + @if (ws.id === panels.activeWorkspaceId()) { +
+ } +
+ } +
+ + +
+ + + @if (updater.state() !== 'idle' && updater.state() !== 'error') { + + } + + + + + + + + @if (settingsOpen()) { +
+
+

{{ i18n.t('options.title') }}

+ +
+ {{ i18n.t('options.language') }} + +
+ +
+ {{ i18n.t('options.theme.label') }} +
+ @for (theme of themes; track theme) { + + } +
+
+ + @if (settings.effectiveDark()) { +
+ {{ i18n.t('options.tone.label') }} +
+ @for (tone of darkTones; track tone) { + + } +
+
+ } + +
+ {{ i18n.t('options.accent.label') }} +
+ @for (tone of accents; track tone.value) { + + } +
+
+ +
+ {{ i18n.t('options.fileIcons.label') }} +
+ @for (mode of iconColorModes; track mode) { + + } +
+
+ +
+ {{ i18n.t('options.interface.label') }} +
+ @for (size of textSizes; track size) { + + } +
+
+ +
+ {{ i18n.t('options.speedUnit.label') }} +
+ @for (unit of speedUnits; track unit.value) { + + } +
+
+ +
+ {{ i18n.t('options.transfers.label') }} +
+ @for (mode of transferModes; track mode) { + + } +
+
+ + @if (settings.transferMode() === 'multi') { +
+ {{ i18n.t('options.atOnce') }} +
+ @for (count of concurrencyChoices; track count) { + + } +
+
+ } + +
+ {{ i18n.t('options.editor.label') }} +
+ + +
+
+ + @if (settings.editor() === 'custom') { +
+ + {{ editorAppName() }} + +
+ + @if (settings.editorApp()) { + + } +
+
+ } + +
+ {{ i18n.t('options.finishedTransfers.label') }} +
+ + +
+
+ + + + + +
+ } +
+ `, +}) +export class TabBar { + readonly panels = inject(PanelsService); + readonly settings = inject(SettingsService); + readonly i18n = inject(I18nService); + readonly updater = inject(UpdaterService); + private readonly rpc = inject(RpcService); + readonly settingsOpen = signal(false); + readonly localeOptions = SUPPORTED; + readonly localeLabels = LOCALE_LABELS; + readonly isMacChrome = IS_MAC_CHROME; + readonly modLabel = MOD_LABEL; + readonly appName = BRANDING.appName; + readonly buildLabel = BRANDING.buildLabel; + readonly themes = THEMES; + readonly darkTones = DARK_TONES; + readonly accents = ACCENTS; + readonly iconColorModes = ICON_COLOR_MODES; + readonly speedUnits = SPEED_UNITS; + readonly textSizes = TEXT_SIZES; + readonly transferModes = TRANSFER_MODES; + readonly concurrencyChoices = CONCURRENCY_CHOICES; + + // drag & drop reordering + readonly draggedId = signal(null); + readonly dropTarget = signal<{ index: number; before: boolean } | null>(null); + + private readonly tabScroll = viewChild.required>('tabScroll'); + + constructor() { + // keep the active tab visible when the strip overflows + effect(() => { + const id = this.panels.activeWorkspaceId(); + this.tabScroll() + .nativeElement.querySelector(`[data-tab-id="${id}"]`) + ?.scrollIntoView({ inline: 'nearest', block: 'nearest' }); + }); + } + + close(event: MouseEvent, id: number): void { + event.stopPropagation(); + this.panels.closeWorkspace(id); + } + + /** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */ + editorAppName(): string { + const path = this.settings.editorApp(); + if (!path) return this.i18n.t('options.systemDefault'); + const base = path.split('/').filter(Boolean).pop() ?? path; + return base.replace(/\.app$/i, ''); + } + + openManager(): void { + void this.rpc.request.openConnectionManager({}); + } + + /** Update pill: first click downloads, second click (when ready) applies + relaunches. */ + onUpdateClick(): void { + const state = this.updater.state(); + if (state === 'available') void this.updater.download(); + else if (state === 'ready') this.updater.apply(); + } + + showAbout(): void { + void this.rpc.request.showAbout({}); + } + + async pickEditorApp(): Promise { + const res = await this.rpc.request.pickEditorApp({}); + if (res.ok && res.path) this.settings.setEditorApp(res.path); + } + + /** Insertion line at the drop position (inset shadow: no layout shift). */ + dropIndicatorShadow(index: number): string | null { + const target = this.dropTarget(); + if (!target || target.index !== index || this.draggedId() === null) return null; + return target.before + ? 'inset 2px 0 0 var(--primary)' + : 'inset -2px 0 0 var(--primary)'; + } + + onDragStart(event: DragEvent, id: number): void { + this.draggedId.set(id); + // WebKit refuses to start a drag without data + event.dataTransfer?.setData('text/plain', String(id)); + if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; + } + + onDragOver(event: DragEvent, index: number): void { + if (this.draggedId() === null) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + const el = event.currentTarget as HTMLElement; + const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2; + this.dropTarget.set({ index, before }); + } + + onDrop(event: DragEvent, index: number): void { + event.preventDefault(); + const id = this.draggedId(); + const target = this.dropTarget(); + this.onDragEnd(); + if (id === null || !target) return; + const list = this.panels.workspaces(); + const fromIndex = list.findIndex((w) => w.id === id); + if (fromIndex < 0) return; + let toIndex = target.before ? index : index + 1; + if (fromIndex < toIndex) toIndex--; // account for removal before insert + if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex); + } + + onDragEnd(): void { + this.draggedId.set(null); + this.dropTarget.set(null); + } +} diff --git a/ui/src/app/skins/shadlike/toasts/toasts.ts b/ui/src/app/skins/shadlike/toasts/toasts.ts new file mode 100644 index 0000000..a09a13d --- /dev/null +++ b/ui/src/app/skins/shadlike/toasts/toasts.ts @@ -0,0 +1,50 @@ +import { Component, inject } from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { ToastService } from '@core/toast.service'; + +/** Toast stack overlay; shared by the main window and editor windows. */ +@Component({ + selector: 'app-toasts', + imports: [NgIcon], + template: ` +
+ @for (t of toast.toasts(); track t.id) { +
+ + {{ t.text }} + + +
+
+ } +
+ `, +}) +export class Toasts { + readonly toast = inject(ToastService); + + toastIcon(kind: 'error' | 'info' | 'success'): string { + if (kind === 'error') return 'mynaXCircle'; + if (kind === 'success') return 'mynaCheckCircle'; + return 'mynaCheck'; + } +} diff --git a/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.html b/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.html new file mode 100644 index 0000000..afd26a6 --- /dev/null +++ b/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.html @@ -0,0 +1,50 @@ +@if (transfers.expanded() && transfers.hasJobs()) { +
+ + {{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }} + + +
+
+ @for (job of transfers.jobs(); track job.id) { +
+ + {{ job.name }} +
+
{{ progressLabel(job) }}
+ @if (isActive(job)) { +
+
+
+ } +
+ @if (isActive(job)) { + + } @else { + + } +
+ } +
+} diff --git a/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.ts b/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.ts new file mode 100644 index 0000000..a23ed12 --- /dev/null +++ b/ui/src/app/skins/shadlike/transfer-bar/transfer-bar.ts @@ -0,0 +1,82 @@ +import { Component, inject } from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import type { TransferSnapshot } from '@shared/types'; +import { formatEta } from '@core/format'; +import { I18nService } from '@core/i18n.service'; +import { SettingsService } from '@core/settings.service'; +import { TransfersService } from '@core/transfers.service'; + +@Component({ + selector: 'app-transfer-bar', + imports: [NgIcon], + templateUrl: './transfer-bar.html', +}) +export class TransferBar { + readonly i18n = inject(I18nService); + readonly transfers = inject(TransfersService); + readonly settings = inject(SettingsService); + readonly formatSize = (bytes: number): string => this.i18n.size(bytes); + + /** "14.2 MB/s · 0:42 left" while a job is moving bytes. */ + speedLabel(job: TransferSnapshot): string { + if (job.state !== 'running') return ''; + const speed = this.transfers.speeds().get(job.id); + if (!speed) return ''; + const parts = [this.i18n.speed(speed, this.settings.speedUnit())]; + const remaining = job.totalBytes - job.doneBytes; + if (remaining > 0 && speed > 0) { + parts.push(this.i18n.t('transfer.left', { eta: formatEta(remaining / speed) })); + } + return parts.join(' · '); + } + + stateIcon(job: TransferSnapshot): string { + switch (job.state) { + case 'completed': + return 'mynaCheckCircle'; + case 'failed': + case 'cancelled': + return 'mynaXCircle'; + default: + return 'mynaSpinner'; + } + } + + stateClass(job: TransferSnapshot): string { + switch (job.state) { + case 'completed': + return 'shrink-0 text-success'; + case 'failed': + return 'shrink-0 text-destructive'; + case 'cancelled': + return 'shrink-0 text-muted-foreground'; + default: + return 'shrink-0 animate-spin text-primary'; + } + } + + isActive(job: TransferSnapshot): boolean { + return job.state === 'queued' || job.state === 'scanning' || job.state === 'running'; + } + + jobPercent(job: TransferSnapshot): number { + if (!job.totalBytes) return job.state === 'completed' ? 100 : 0; + return Math.min(100, (job.doneBytes / job.totalBytes) * 100); + } + + progressLabel(job: TransferSnapshot): string { + if (job.state === 'queued') return this.i18n.t('transfer.queuedState'); + if (job.state === 'scanning') return this.i18n.t('transfer.scanning'); + if (job.state === 'failed') + return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? this.i18n.t('transfer.failedState')}`; + if (job.state === 'cancelled') return this.i18n.t('transfer.cancelled'); + if (job.state === 'completed') + return this.i18n.t('transfer.completedFiles', { + count: job.doneFiles, + size: this.i18n.size(job.doneBytes), + }); + const speed = this.speedLabel(job); + const base = `${job.currentFile} · ${this.i18n.size(job.doneBytes)} / ${this.i18n.size(job.totalBytes)}`; + return speed ? `${base} · ${speed}` : base; + } +}