AppSettings.textSize (compact|default|large) becomes uiScale: number, 0.5-4 in 0.1 steps, default 1. One shared clampUiScale (next to the effectiveConcurrency precedent) defines "valid" for the store, the service and the stepper: clamp to range, snap to the step grid, strip float noise, and map NaN/Infinity to 1. - SettingsStore.load migrates legacy files (compact/default/large -> 0.9/1/1.2, never over an explicit uiScale) and clamps ALWAYS, not just on migration — buildPreload writes this value into every window's root font-size, so a hand-edited settings.json (uiScale: 100) must not be able to render the UI unusable. Verified against 100, 0.001, -3, NaN, Infinity and float noise; migration verified against a real legacy file. - SettingsService: applyUiScale sets the root font-size (16 * scale, removed entirely at 1 per the delete-when-default rule). Rides the existing settingsChanged broadcast, so open editor/manager windows follow live. - buildPreload bakes the same font-size so the first paint is already scaled — without it every window opens at 100% and snaps. - Both tab-bars: the 3-button Interface row becomes the stepper. Clicking the percentage resets to 100% (tooltip says so). i18n: compact/default/large dropped, smaller/larger/reset added, all 7 locales. Verified live, win7 + modern: scale 1 is pixel-identical to the pre-work baseline (opaque interior diff exactly 0); 0.5x is a real dense mode (~2x rows); 2x scales title bar, tabs, rows, icons and controls together with text contained in its boxes — which is what stage 1c's 7.css bridge bought; 4x is enormous but coherent, and the win7 title-bar controls stay on-screen at the default window size. The known caveat stands: no minimum window size exists, so 4x in a small window is legal but cramped. Co-Authored-By: Claude <noreply@anthropic.com>
191 lines
6.1 KiB
TypeScript
191 lines
6.1 KiB
TypeScript
export type EntryType = "file" | "dir" | "symlink";
|
||
|
||
export interface FileEntry {
|
||
name: string;
|
||
path: string; // absolute path on its filesystem
|
||
type: EntryType;
|
||
size: number; // bytes
|
||
mtime: number; // epoch milliseconds
|
||
mode: number; // permission bits only (masked & 0o7777)
|
||
owner?: string; // resolved name when the provider knows it
|
||
group?: string;
|
||
}
|
||
|
||
/** Optional file-list columns (Name is always shown). */
|
||
export type ColumnKey = "size" | "mtime" | "kind" | "rights" | "owner" | "group";
|
||
|
||
/** Every RPC response uses this envelope; errors never cross the bridge as exceptions. */
|
||
export type Result<T = object> = ({ ok: true } & T) | { ok: false; error: string };
|
||
|
||
export type AuthConfig =
|
||
| { kind: "password"; password: string }
|
||
| { kind: "key"; privateKeyPath: string; passphrase?: string }
|
||
| { kind: "agent" };
|
||
|
||
export interface ConnectionConfig {
|
||
host: string;
|
||
port: number;
|
||
username: string;
|
||
auth: AuthConfig;
|
||
initialPath?: string | null;
|
||
}
|
||
|
||
export interface SavedConnection extends ConnectionConfig {
|
||
id: string;
|
||
stackId: string | null;
|
||
name: string;
|
||
/** Other-panel local dir applied when that panel is local and untouched. */
|
||
localDir?: string | null;
|
||
}
|
||
|
||
/** A named group of connections that can be opened together as tabs. */
|
||
export interface Stack {
|
||
id: string;
|
||
name: string;
|
||
/** Collapsed in the connection-manager list (UI state, persisted). */
|
||
collapsed?: boolean;
|
||
}
|
||
|
||
/** A concrete host alias resolved from ~/.ssh/config (read-only). */
|
||
export interface SshConfigHost {
|
||
name: string; // the Host alias as written
|
||
hostname: string; // resolved HostName (defaults to alias)
|
||
port: number;
|
||
username: string;
|
||
identityFile?: string; // first existing IdentityFile, absolute path
|
||
unsupported?: "proxy"; // ProxyJump/ProxyCommand present — direct connect can't work
|
||
}
|
||
|
||
export interface ConnectionsFile {
|
||
version: 2;
|
||
stacks: Stack[];
|
||
connections: SavedConnection[];
|
||
}
|
||
|
||
/** UI languages the app ships translations for. */
|
||
export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
|
||
|
||
export type AccentTone =
|
||
| "graphite"
|
||
| "blue"
|
||
| "indigo"
|
||
| "purple"
|
||
| "rose"
|
||
| "orange"
|
||
| "green"
|
||
| "teal";
|
||
|
||
export interface AppSettings {
|
||
/** UI language; "system" resolves to the OS locale (fallback English). */
|
||
locale: "system" | SupportedLocale;
|
||
/**
|
||
* Visual skin. "modern" is the shadcn-style look; "win7" swaps in 7.css
|
||
* (Windows 7 Aero) and switches the window to custom frameless chrome.
|
||
* Each skin is a whole component tree under ui/src/app/skins/ — "modern" is
|
||
* shadlike/, "win7" is 7like/ — selected at bootstrap in ui/src/main.ts.
|
||
* The value is NOT named after its folder on purpose: it is persisted in
|
||
* user settings, and "win7" is also the literal class 7.scoped.css is
|
||
* precompiled to require, so neither can be renamed freely.
|
||
* Changing this needs a window reopen to take effect on the frame itself —
|
||
* the CSS half applies live.
|
||
*/
|
||
skin: "modern" | "win7";
|
||
theme: "system" | "light" | "dark";
|
||
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
|
||
darkTone: "dark" | "darker";
|
||
/** Accent tone for chrome and selection tint; graphite = neutral zinc. */
|
||
accent: AccentTone;
|
||
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
|
||
iconColors: "plain" | "multi";
|
||
/** Visible optional file-list columns (both panels; Name always shows). */
|
||
columns: ColumnKey[];
|
||
speedUnit: "MBps" | "Mbps";
|
||
/**
|
||
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
|
||
* setting the root font-size — every size in the UI is rem, so one lever
|
||
* scales BOTH skins whole. Replaced the 3-tier `textSize`
|
||
* (compact|default|large); SettingsStore.load migrates old files.
|
||
*/
|
||
uiScale: number;
|
||
/** Run transfer jobs one at a time, or several in parallel. */
|
||
transferMode: "single" | "multi";
|
||
/** How many jobs run at once when transferMode is "multi". */
|
||
transferConcurrency: number;
|
||
/** Remove completed transfers from the list automatically. */
|
||
autoClearCompleted: boolean;
|
||
/** Edit files with the built-in editor or an external application. */
|
||
editor: "builtin" | "custom";
|
||
/** Full path to the external editor .app; empty = OS default application. */
|
||
editorApp: string;
|
||
// Auto-refresh the active tab's panels (local watched, remote polled). Always
|
||
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
|
||
// gate the poll/watch in panels.service) to expose a toggle later.
|
||
// autoRefresh: boolean;
|
||
}
|
||
|
||
export const DEFAULT_SETTINGS: AppSettings = {
|
||
locale: "system",
|
||
skin: "modern",
|
||
theme: "system",
|
||
darkTone: "dark",
|
||
accent: "blue",
|
||
iconColors: "multi",
|
||
columns: ["size", "mtime"],
|
||
speedUnit: "MBps",
|
||
uiScale: 1,
|
||
transferMode: "single",
|
||
transferConcurrency: 3,
|
||
autoClearCompleted: true,
|
||
editor: "builtin",
|
||
editorApp: "",
|
||
// autoRefresh: true,
|
||
};
|
||
|
||
export const UI_SCALE_MIN = 0.5;
|
||
export const UI_SCALE_MAX = 4;
|
||
export const UI_SCALE_STEP = 0.1;
|
||
|
||
/**
|
||
* Clamp + snap a scale factor to the legal range and step grid. Shared by the
|
||
* store (a hand-edited settings.json must not be able to render the UI
|
||
* unusable), the settings service and the stepper — one definition of "valid",
|
||
* same as effectiveConcurrency below.
|
||
*/
|
||
export function clampUiScale(value: number): number {
|
||
if (!Number.isFinite(value)) return 1;
|
||
const clamped = Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, value));
|
||
// snap to the step grid; toFixed strips float noise (0.7000000000000001 -> 0.7)
|
||
return Number((Math.round(clamped / UI_SCALE_STEP) * UI_SCALE_STEP).toFixed(2));
|
||
}
|
||
|
||
export function effectiveConcurrency(settings: AppSettings): number {
|
||
if (settings.transferMode === "single") return 1;
|
||
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
|
||
}
|
||
|
||
export type TransferState =
|
||
| "queued"
|
||
| "scanning"
|
||
| "running"
|
||
| "completed"
|
||
| "failed"
|
||
| "cancelled";
|
||
|
||
export interface TransferSnapshot {
|
||
id: string;
|
||
name: string; // display name (basename of source)
|
||
srcSessionId: string;
|
||
srcPath: string;
|
||
dstSessionId: string;
|
||
dstDir: string;
|
||
state: TransferState;
|
||
totalFiles: number;
|
||
totalBytes: number;
|
||
doneFiles: number;
|
||
doneBytes: number;
|
||
currentFile: string;
|
||
currentFileBytes: number;
|
||
currentFileTotal: number;
|
||
error?: string;
|
||
}
|