v1.2: overwrite confirm, transfer speed/ETA, settings + themes, fused macOS titlebar
- Overwrite confirmation: name-collision detection before enqueue; dialog shows source vs destination (size, mtime, 'newer' badge) with per-item Skip/Replace and batch Skip all / Replace all; type mismatches auto-skip with a toast; cancel aborts the whole operation - Transfer speed + ETA: UI computes smoothed bytes/sec from progress deltas (callback bus makes deltas meaningful); shown in the expanded queue rows and the status-bar summary; unit follows the speed-unit setting - Settings: bun-side SettingsStore (settings.json, 0600, atomic) + get/save RPC; SettingsService; gear popover on the tab row (theme segmented control, speed unit); persisted across restarts - Light theme: data-theme on <html> drives token overrides; absent = follow OS - macOS fused titlebar: hiddenInset + trafficLightOffset, tab row becomes the draggable titlebar (app-region drag/no-drag classes); other platforms keep a separate titlebar (gated by a platform value injected via window preload) - README: known-issues note for the upstream Bun/Electrobun FFI crash Verified: tsc + ui build clean; SettingsStore roundtrip + 0600 headless; app boots and the UI fetches settings over RPC at startup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,14 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
||||
sessions stay connected and transfers keep running in background tabs.
|
||||
- Per-panel action toolbar with a direction-explicit copy button, plus a
|
||||
VSCode-style status bar showing live transfer progress (click to expand the
|
||||
transfer queue).
|
||||
transfer queue with per-file speed and ETA).
|
||||
- **Overwrite confirmation** on name collisions, with a source-vs-destination
|
||||
comparison (size, modified date, which is newer) and batch replace-all /
|
||||
skip-all.
|
||||
- **Settings** (gear, top-right): light / dark / system theme and speed units
|
||||
(MB/s or Mbps), persisted across restarts.
|
||||
- On macOS the tab bar is fused with the window titlebar; other platforms keep
|
||||
a standard titlebar.
|
||||
- Browse, sort (name/size/modified, folders first), keyboard navigation.
|
||||
- Create folder / file, rename, delete (recursive), change permissions (chmod).
|
||||
- Upload, download, and remote→remote copy — files **and** whole folders —
|
||||
@@ -129,6 +136,17 @@ Saved connections live in a plain JSON file in the OS app-data directory
|
||||
|
||||
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
||||
|
||||
## Known issues
|
||||
|
||||
- **Rare native crash under load.** A long-running session (multiple remote
|
||||
connections, extended uptime) can hit a Bun panic — a segfault inside Bun
|
||||
1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker
|
||||
bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded
|
||||
crash report). This is an upstream Bun × Electrobun bug, not app code; a
|
||||
restart recovers cleanly and in-flight work isn't corrupted. Expected to
|
||||
resolve when Electrobun bumps its pinned Bun (currently 1.3.13; 1.3.14+
|
||||
available). Bun's own crash reporter link is printed to the console on panic.
|
||||
|
||||
## Not in v1
|
||||
|
||||
Drag-and-drop, file preview/edit, transfer resume, symlink following
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"typescript": "~5.9.0"
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"trustedDependencies": ["@parcel/watcher"]
|
||||
}
|
||||
|
||||
+13
-1
@@ -3,6 +3,7 @@ import { BRANDING } from "../shared/branding";
|
||||
import { SessionRegistry } from "./fs/registry";
|
||||
import { createAppRPC } from "./rpc-handlers";
|
||||
import { ConnectionStore } from "./store/connections";
|
||||
import { SettingsStore } from "./store/settings";
|
||||
import { TransferEngine } from "./transfers/engine";
|
||||
|
||||
const PROD_URL = "views://app/index.html";
|
||||
@@ -28,10 +29,13 @@ async function getMainViewUrl(): Promise<string> {
|
||||
const registry = new SessionRegistry();
|
||||
const engine = new TransferEngine(registry);
|
||||
const store = new ConnectionStore();
|
||||
const settings = new SettingsStore();
|
||||
const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {} };
|
||||
const rpc = createAppRPC({ registry, engine, store, events });
|
||||
const rpc = createAppRPC({ registry, engine, store, settings, events });
|
||||
const url = await getMainViewUrl();
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
new BrowserWindow({
|
||||
title: BRANDING.appName,
|
||||
url,
|
||||
@@ -41,6 +45,14 @@ new BrowserWindow({
|
||||
x: 120,
|
||||
y: 120,
|
||||
},
|
||||
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the
|
||||
// UI's h-10 tab row (offset centers them vertically). Other platforms keep
|
||||
// the native titlebar and the tab bar stays a separate row.
|
||||
...(isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {}),
|
||||
// lets the UI pick the fused layout before first paint
|
||||
preload: `window.__BUNSCP_PLATFORM=${JSON.stringify(process.platform)};`,
|
||||
rpc,
|
||||
});
|
||||
|
||||
|
||||
+12
-1
@@ -4,6 +4,7 @@ import type { Result } from "../shared/types";
|
||||
import type { SessionRegistry } from "./fs/registry";
|
||||
import { SftpProvider } from "./fs/sftp";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
import type { SettingsStore } from "./store/settings";
|
||||
import type { TransferEngine } from "./transfers/engine";
|
||||
import { removeRecursive } from "./transfers/walk";
|
||||
|
||||
@@ -15,6 +16,7 @@ export interface AppDeps {
|
||||
registry: SessionRegistry;
|
||||
engine: TransferEngine;
|
||||
store: ConnectionStore;
|
||||
settings: SettingsStore;
|
||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||
events: { sessionClosed: (info: { sessionId: string; reason: string }) => void };
|
||||
}
|
||||
@@ -27,7 +29,7 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
||||
}
|
||||
}
|
||||
|
||||
export function createAppRPC({ registry, engine, store, events }: AppDeps) {
|
||||
export function createAppRPC({ registry, engine, store, settings, events }: AppDeps) {
|
||||
return BrowserView.defineRPC<AppRPC>({
|
||||
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||
handlers: {
|
||||
@@ -135,6 +137,15 @@ export function createAppRPC({ registry, engine, store, events }: AppDeps) {
|
||||
await store.deleteFolder(id);
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- settings ----
|
||||
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
|
||||
|
||||
saveSettings: ({ settings: next }) =>
|
||||
wrap(async () => {
|
||||
await settings.save(next);
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||
import { appDataDir } from "./paths";
|
||||
|
||||
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
|
||||
export class SettingsStore {
|
||||
private readonly file = path.join(appDataDir(), "settings.json");
|
||||
|
||||
async load(): Promise<AppSettings> {
|
||||
try {
|
||||
const data = JSON.parse(await fs.readFile(this.file, "utf8"));
|
||||
return { ...DEFAULT_SETTINGS, ...data };
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
async save(settings: AppSettings): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
||||
const tmp = `${this.file}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(settings, null, "\t"), { mode: 0o600 });
|
||||
await fs.rename(tmp, this.file);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
ConnFolder,
|
||||
ConnectionConfig,
|
||||
ConnectionsFile,
|
||||
@@ -67,6 +68,12 @@ export type AppRPC = {
|
||||
deleteConnection: { params: { id: string }; response: Result };
|
||||
upsertFolder: { params: { folder: ConnFolder }; response: Result<{ id: string }> };
|
||||
deleteFolder: { params: { id: string }; response: Result };
|
||||
// settings
|
||||
getSettings: {
|
||||
params: Record<string, never>;
|
||||
response: Result<{ settings: AppSettings }>;
|
||||
};
|
||||
saveSettings: { params: { settings: AppSettings }; response: Result };
|
||||
};
|
||||
messages: Record<never, never>;
|
||||
};
|
||||
|
||||
@@ -41,6 +41,16 @@ export interface ConnectionsFile {
|
||||
connections: SavedConnection[];
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
theme: "system" | "light" | "dark";
|
||||
speedUnit: "MBps" | "Mbps";
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
theme: "system",
|
||||
speedUnit: "MBps",
|
||||
};
|
||||
|
||||
export type TransferState =
|
||||
| "queued"
|
||||
| "scanning"
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
@if (dialogs.chmod(); as req) {
|
||||
<app-chmod-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.overwrite(); as req) {
|
||||
<app-overwrite-dialog [request]="req" />
|
||||
}
|
||||
|
||||
<!-- toasts -->
|
||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BRANDING } from '@shared/branding';
|
||||
import { ConnectionManager } from './connection-manager/connection-manager';
|
||||
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 './dialogs.service';
|
||||
import { FilePanel } from './file-panel/file-panel';
|
||||
@@ -24,6 +25,7 @@ import { TransferBar } from './transfer-bar/transfer-bar';
|
||||
PromptDialog,
|
||||
ConfirmDialog,
|
||||
ChmodDialog,
|
||||
OverwriteDialog,
|
||||
TransferBar,
|
||||
ConnectionManager,
|
||||
],
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import type { FileEntry } from '@shared/types';
|
||||
|
||||
export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel';
|
||||
|
||||
export interface OverwriteRequest {
|
||||
srcEntry: FileEntry;
|
||||
dstEntry: FileEntry;
|
||||
index: number; // 1-based position within the colliding items
|
||||
total: number; // number of colliding items in this batch
|
||||
resolve: (choice: OverwriteChoice) => void;
|
||||
}
|
||||
|
||||
export interface PromptRequest {
|
||||
title: string;
|
||||
@@ -27,8 +38,16 @@ export class DialogsService {
|
||||
readonly prompt = signal<PromptRequest | null>(null);
|
||||
readonly confirm = signal<ConfirmRequest | null>(null);
|
||||
readonly chmod = signal<ChmodRequest | null>(null);
|
||||
readonly overwrite = signal<OverwriteRequest | null>(null);
|
||||
readonly manager = signal(false);
|
||||
|
||||
/** Ask what to do about one name collision; resolves with the user's choice. */
|
||||
askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> {
|
||||
return new Promise((resolve) => {
|
||||
this.overwrite.set({ ...request, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
get anyOpen(): boolean {
|
||||
return !!(this.prompt() || this.confirm() || this.chmod());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service';
|
||||
import { formatDate, formatSize } from '../format';
|
||||
import { PanelsService } from '../panels.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-overwrite-dialog',
|
||||
imports: [NgIcon],
|
||||
template: `
|
||||
<div
|
||||
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
|
||||
(keydown.escape)="pick('cancel')"
|
||||
>
|
||||
<div
|
||||
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h2 class="min-w-0 text-sm font-semibold">
|
||||
{{ isDir() ? 'Merge folder' : 'Replace' }}
|
||||
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
|
||||
</h2>
|
||||
@if (request().total > 1) {
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{{ request().index }} of {{ request().total }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
@if (isDir()) {
|
||||
The destination already has a folder with this name. Merging may replace files inside
|
||||
it.
|
||||
} @else {
|
||||
The destination already has a file with this name.
|
||||
}
|
||||
</p>
|
||||
|
||||
<!-- comparison -->
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
@for (side of comparison(); track side.label) {
|
||||
<div class="rounded-md border border-border p-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{{ side.label }}
|
||||
</span>
|
||||
@if (side.newer) {
|
||||
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success">
|
||||
newer
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
|
||||
<ng-icon
|
||||
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
|
||||
size="0.9rem"
|
||||
class="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="font-mono tabular-nums">
|
||||
{{ isDir() ? '—' : formatSize(side.entry.size) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground">
|
||||
{{ formatDate(side.entry.mtime) }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
(click)="pick('cancel')"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
@if (request().total > 1) {
|
||||
<button
|
||||
(click)="pick('skipAll')"
|
||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Skip all
|
||||
</button>
|
||||
<button
|
||||
(click)="pick('overwriteAll')"
|
||||
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ isDir() ? 'Merge all' : 'Replace all' }}
|
||||
</button>
|
||||
<div class="h-4 w-px bg-border"></div>
|
||||
}
|
||||
<button
|
||||
(click)="pick('skip')"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
#primaryBtn
|
||||
(click)="pick('overwrite')"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ isDir() ? 'Merge' : 'Replace' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OverwriteDialog {
|
||||
readonly request = input.required<OverwriteRequest>();
|
||||
|
||||
readonly formatSize = formatSize;
|
||||
readonly formatDate = formatDate;
|
||||
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly panels = inject(PanelsService);
|
||||
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('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.panels.refocus();
|
||||
req.resolve(choice);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,25 @@ export function formatDate(epochMs: number): string {
|
||||
return DATE_FORMAT.format(new Date(epochMs));
|
||||
}
|
||||
|
||||
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
|
||||
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps'): string {
|
||||
if (unit === 'Mbps') {
|
||||
return `${((bytesPerSec * 8) / 1_000_000).toFixed(1)} Mbps`;
|
||||
}
|
||||
return `${formatSize(bytesPerSec)}/s`;
|
||||
}
|
||||
|
||||
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
|
||||
export function formatEta(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '';
|
||||
const s = Math.round(seconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
const mmss = `${m}:${String(sec).padStart(2, '0')}`;
|
||||
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
|
||||
}
|
||||
|
||||
/** POSIX-style join for UI-composed paths (both panels use "/" on macOS + remote). */
|
||||
export function joinPath(dir: string, name: string): string {
|
||||
return dir.endsWith('/') ? dir + name : `${dir}/${name}`;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Injected by the bun process via BrowserWindow preload before any page script.
|
||||
declare global {
|
||||
interface Window {
|
||||
__BUNSCP_PLATFORM?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const PLATFORM = typeof window !== 'undefined' ? (window.__BUNSCP_PLATFORM ?? '') : '';
|
||||
|
||||
/** True when the window uses macOS hiddenInset chrome (tab bar fused with titlebar). */
|
||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { DEFAULT_SETTINGS, type AppSettings } from '@shared/types';
|
||||
import { RpcService } from './rpc.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsService {
|
||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
|
||||
constructor() {
|
||||
if (!this.rpc.available) return;
|
||||
void this.rpc.request.getSettings({}).then((res) => {
|
||||
if (!res.ok) return;
|
||||
this.theme.set(res.settings.theme);
|
||||
this.speedUnit.set(res.settings.speedUnit);
|
||||
this.applyTheme();
|
||||
});
|
||||
}
|
||||
|
||||
setTheme(theme: AppSettings['theme']): void {
|
||||
this.theme.set(theme);
|
||||
this.applyTheme();
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setSpeedUnit(unit: AppSettings['speedUnit']): void {
|
||||
this.speedUnit.set(unit);
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
private applyTheme(): void {
|
||||
const theme = this.theme();
|
||||
if (theme === 'system') {
|
||||
delete document.documentElement.dataset['theme'];
|
||||
} else {
|
||||
document.documentElement.dataset['theme'] = theme;
|
||||
}
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await this.rpc.request.saveSettings({
|
||||
settings: { theme: this.theme(), speedUnit: this.speedUnit() },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
import { formatSpeed } from '../format';
|
||||
import { PanelsService } from '../panels.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
|
||||
@Component({
|
||||
@@ -20,7 +22,7 @@ import { TransfersService } from '../transfers.service';
|
||||
@if (transfers.currentJob(); as job) {
|
||||
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
|
||||
<span class="truncate">
|
||||
Copying {{ job.name }} · {{ percent(job) }}%
|
||||
Copying {{ job.name }} · {{ percent(job) }}%{{ speedSuffix(job) }}
|
||||
@if (transfers.queuedCount(); as queued) {
|
||||
· {{ queued }} queued
|
||||
}
|
||||
@@ -57,10 +59,16 @@ import { TransfersService } from '../transfers.service';
|
||||
export class StatusBar {
|
||||
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())}` : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { PanelsService } from '../panels.service';
|
||||
import { IS_MAC_CHROME } from '../platform';
|
||||
import { SettingsService } from '../settings.service';
|
||||
|
||||
const THEMES = ['system', 'light', 'dark'] as const;
|
||||
const SPEED_UNITS = [
|
||||
{ value: 'MBps', label: 'MB/s' },
|
||||
{ value: 'Mbps', label: 'Mbps' },
|
||||
] as const;
|
||||
|
||||
@Component({
|
||||
selector: 'app-tab-bar',
|
||||
imports: [NgIcon],
|
||||
template: `
|
||||
<div class="flex items-center gap-0.5 border-b border-border px-1.5 pt-1">
|
||||
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
|
||||
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
|
||||
<div
|
||||
class="relative flex items-center gap-0.5 border-b border-border pr-1.5 electrobun-webkit-app-region-drag"
|
||||
[class.h-10]="isMacChrome"
|
||||
[class.pl-20]="isMacChrome"
|
||||
[class.pl-1.5]="!isMacChrome"
|
||||
[class.pt-1]="!isMacChrome"
|
||||
>
|
||||
@for (ws of panels.workspaces(); track ws.id) {
|
||||
<div
|
||||
class="group relative flex max-w-52 items-center rounded-t-md"
|
||||
class="group relative flex max-w-52 items-center rounded-md electrobun-webkit-app-region-no-drag"
|
||||
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
||||
>
|
||||
<button
|
||||
@@ -46,15 +62,74 @@ import { PanelsService } from '../panels.service';
|
||||
<button
|
||||
(click)="panels.newWorkspace()"
|
||||
title="New tab (⌘T)"
|
||||
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaPlus" size="0.9rem" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- global options -->
|
||||
<button
|
||||
(click)="settingsOpen.set(!settingsOpen())"
|
||||
title="Options"
|
||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaCog" size="0.95rem" />
|
||||
</button>
|
||||
|
||||
@if (settingsOpen()) {
|
||||
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
|
||||
<div
|
||||
class="absolute top-full right-1.5 z-30 mt-1 w-64 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
|
||||
>
|
||||
<h3 class="text-xs font-semibold">Options</h3>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Theme</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (theme of themes; track theme) {
|
||||
<button
|
||||
(click)="settings.setTheme(theme)"
|
||||
class="rounded px-2 py-0.5 text-[11px] capitalize"
|
||||
[class.bg-accent]="settings.theme() === theme"
|
||||
[class.text-foreground]="settings.theme() === theme"
|
||||
[class.text-muted-foreground]="settings.theme() !== theme"
|
||||
>
|
||||
{{ theme }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Speed unit</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (unit of speedUnits; track unit.value) {
|
||||
<button
|
||||
(click)="settings.setSpeedUnit(unit.value)"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
[class.bg-accent]="settings.speedUnit() === unit.value"
|
||||
[class.text-foreground]="settings.speedUnit() === unit.value"
|
||||
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
||||
>
|
||||
{{ unit.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class TabBar {
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly themes = THEMES;
|
||||
readonly speedUnits = SPEED_UNITS;
|
||||
|
||||
close(event: MouseEvent, id: number): void {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
import { formatSize } from '../format';
|
||||
import { formatEta, formatSize, formatSpeed } from '../format';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
|
||||
@Component({
|
||||
@@ -11,8 +12,20 @@ import { TransfersService } from '../transfers.service';
|
||||
})
|
||||
export class TransferBar {
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly formatSize = formatSize;
|
||||
|
||||
/** "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 = [formatSpeed(speed, this.settings.speedUnit())];
|
||||
const remaining = job.totalBytes - job.doneBytes;
|
||||
if (remaining > 0 && speed > 0) parts.push(`${formatEta(remaining / speed)} left`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
stateIcon(job: TransferSnapshot): string {
|
||||
switch (job.state) {
|
||||
case 'completed':
|
||||
@@ -54,6 +67,8 @@ export class TransferBar {
|
||||
if (job.state === 'cancelled') return 'Cancelled';
|
||||
if (job.state === 'completed')
|
||||
return `${job.doneFiles} file${job.doneFiles === 1 ? '' : 's'} · ${formatSize(job.doneBytes)}`;
|
||||
return `${job.currentFile} · ${formatSize(job.doneBytes)} / ${formatSize(job.totalBytes)}`;
|
||||
const speed = this.speedLabel(job);
|
||||
const base = `${job.currentFile} · ${formatSize(job.doneBytes)} / ${formatSize(job.totalBytes)}`;
|
||||
return speed ? `${base} · ${speed}` : base;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
import type { FileEntry, TransferSnapshot } from '@shared/types';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { PanelsService, type Side } from './panels.service';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
const AUTO_DISMISS_COMPLETED_MS = 5000;
|
||||
const ACTIVE_STATES = ['queued', 'scanning', 'running'];
|
||||
// EMA smoothing for the displayed transfer speed
|
||||
const SPEED_SMOOTHING = 0.7;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TransfersService {
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly panels = inject(PanelsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
|
||||
private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map());
|
||||
/** Smoothed bytes/sec per running job (UI-computed from progress deltas). */
|
||||
readonly speeds = signal<ReadonlyMap<string, number>>(new Map());
|
||||
private speedTrack = new Map<string, { bytes: number; time: number; speed: number }>();
|
||||
readonly jobs = computed(() => [...this.jobMap().values()]);
|
||||
readonly hasJobs = computed(() => this.jobMap().size > 0);
|
||||
readonly activeJobs = computed(() => this.jobs().filter((j) => ACTIVE_STATES.includes(j.state)));
|
||||
@@ -51,15 +58,75 @@ export class TransfersService {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalTargets = await this.resolveCollisions(targets, dst.entries());
|
||||
if (finalTargets === null) return; // user cancelled
|
||||
if (!finalTargets.length) {
|
||||
this.toast.info('Nothing to copy — all conflicting items were skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await this.rpc.request.enqueueTransfers({
|
||||
srcSessionId: src.sessionId(),
|
||||
items: targets.map(({ path, type }) => ({ path, type })),
|
||||
items: finalTargets.map(({ path, type }) => ({ path, type })),
|
||||
dstSessionId: dst.sessionId(),
|
||||
dstDir: dst.cwd(),
|
||||
});
|
||||
if (!res.ok) this.toast.error(res.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm name collisions with the destination listing one by one.
|
||||
* Returns the approved target list, or null if the user cancelled.
|
||||
*/
|
||||
private async resolveCollisions(
|
||||
targets: FileEntry[],
|
||||
dstEntries: FileEntry[],
|
||||
): Promise<FileEntry[] | null> {
|
||||
const dstByName = new Map(dstEntries.map((e) => [e.name, e]));
|
||||
const collisions = targets.filter((t) => {
|
||||
const dst = dstByName.get(t.name);
|
||||
return dst && (dst.type === 'dir') === (t.type === 'dir');
|
||||
});
|
||||
|
||||
let mode: 'ask' | 'all' | 'none' = 'ask';
|
||||
let asked = 0;
|
||||
const finalTargets: FileEntry[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const dstEntry = dstByName.get(target.name);
|
||||
if (!dstEntry) {
|
||||
finalTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
|
||||
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`);
|
||||
continue;
|
||||
}
|
||||
if (mode === 'all') {
|
||||
finalTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
if (mode === 'none') continue;
|
||||
|
||||
const choice = await this.dialogs.askOverwrite({
|
||||
srcEntry: target,
|
||||
dstEntry,
|
||||
index: ++asked,
|
||||
total: collisions.length,
|
||||
});
|
||||
if (choice === 'cancel') return null;
|
||||
if (choice === 'overwriteAll') {
|
||||
mode = 'all';
|
||||
finalTargets.push(target);
|
||||
} else if (choice === 'skipAll') {
|
||||
mode = 'none';
|
||||
} else if (choice === 'overwrite') {
|
||||
finalTargets.push(target);
|
||||
}
|
||||
}
|
||||
return finalTargets;
|
||||
}
|
||||
|
||||
async cancel(jobId: string): Promise<void> {
|
||||
await this.rpc.request.cancelTransfer({ jobId });
|
||||
}
|
||||
@@ -70,12 +137,19 @@ export class TransfersService {
|
||||
next.delete(jobId);
|
||||
return next;
|
||||
});
|
||||
this.speedTrack.delete(jobId);
|
||||
this.speeds.update((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(jobId);
|
||||
return next;
|
||||
});
|
||||
await this.rpc.request.dismissTransfer({ jobId });
|
||||
}
|
||||
|
||||
private apply(snapshot: TransferSnapshot): void {
|
||||
const previous = this.jobMap().get(snapshot.id);
|
||||
this.jobMap.update((prev) => new Map(prev).set(snapshot.id, snapshot));
|
||||
this.trackSpeed(snapshot);
|
||||
|
||||
const justFinished = previous?.state !== snapshot.state;
|
||||
if (!justFinished) return;
|
||||
@@ -97,4 +171,25 @@ export class TransfersService {
|
||||
if (panel.sessionId() === sessionId) void panel.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private trackSpeed(snapshot: TransferSnapshot): void {
|
||||
if (snapshot.state !== 'running') {
|
||||
if (snapshot.state !== 'queued' && snapshot.state !== 'scanning') {
|
||||
this.speedTrack.delete(snapshot.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
const prev = this.speedTrack.get(snapshot.id);
|
||||
if (!prev) {
|
||||
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed: 0 });
|
||||
return;
|
||||
}
|
||||
const dt = (now - prev.time) / 1000;
|
||||
if (dt < 0.2) return; // wait for a meaningful window
|
||||
const instant = Math.max(0, (snapshot.doneBytes - prev.bytes) / dt);
|
||||
const speed = prev.speed ? SPEED_SMOOTHING * prev.speed + (1 - SPEED_SMOOTHING) * instant : instant;
|
||||
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed });
|
||||
this.speeds.update((map) => new Map(map).set(snapshot.id, speed));
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -24,8 +24,9 @@
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* Dark tokens apply when the user forces dark, or follows a dark system theme.
|
||||
(data-theme is set by SettingsService; absent = follow the system.) */
|
||||
:root[data-theme='dark'] {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
@@ -44,6 +45,34 @@
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--success: oklch(0.696 0.17 149.2);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user