Remove the Windows 7 (7.css) skin
The win7 skin is dropped. Everything else the branch built stays: Linux support and the HTML context menus, the workspace-tab menu, the continuous interface scale (rem everywhere, stepper, Ctrl/Cmd +/-/0) and the core/ service layer. The modern tree already carried all of them, and nothing outside skins/7like/ imported the win7 tree except main.ts and styles.css. Removed: the skins/7like/ tree (5.1k lines, fonts), the 7.css dependency, the skin setting and its options row + strings in all 7 locales, the frameless-window plumbing (window-control RPCs, windowState push, trackMaximizeState, the synthesized resize grip and its postinstall watchdog), the session-skin pinning in buildPreload/chromeOptions, the skin/skinfix cascade layers, and the win7-only probes and screenshot harness (the harness cannot render the modern tree: plain Firefox has no RPC bridge). chromeOptions() stays as the one shared frame decision. Fixed on the way out: - index.html's per-theme pre-paint background is restored. The branch had replaced it with a win7-only transparent rule, so modern dark windows had silently lost one of the three flash guards. - editor-windows.ts wrote its window-key separator as a literal NUL byte, which made git and grep treat the file as binary; it is `\0` now (same string at runtime). No settings migration: a stale "skin" key is carried harmlessly by the store's spread-merge and dropped on the next save. UI bundle: 616.7 kB -> 405.4 kB, back under the 500 kB budget. Docs: architecture, gotchas and platform-notes lose the win7 material but keep what stays true (construction-time preloads/frames, drag regions ignoring preventDefault, electrobun's missing resize/maximize primitives); flash-prevention, dev-URL and Bun-panic notes move back under Electrobun; stale context-menu/tab-bar links fixed; README documents the scale keys. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,8 @@ export interface MenuItem {
|
||||
tooltip?: string;
|
||||
/** Shortcut hint, rendered right-aligned. */
|
||||
accelerator?: string;
|
||||
/** Greyed and unclickable. Win7 menus disable rather than hide, so the set
|
||||
* of commands stays in the same place from one right-click to the next. */
|
||||
/** Greyed and unclickable. Menus disable rather than hide, so the set of
|
||||
* commands stays in the same place from one right-click to the next. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export class ContextMenuService {
|
||||
* assume `activeWorkspace`.
|
||||
*
|
||||
* Everything illegal is DISABLED, not omitted: a menu whose items move
|
||||
* between right-clicks is a Win7 sin, and "Close Tab" greyed out explains
|
||||
* between right-clicks is worse than a dead row, and "Close Tab" greyed out explains
|
||||
* why the last tab cannot be closed better than its absence does.
|
||||
*
|
||||
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
||||
|
||||
@@ -8,9 +8,6 @@ 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;
|
||||
}
|
||||
@@ -24,8 +21,7 @@ export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
export const IS_LINUX = PLATFORM === 'linux';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
|
||||
* platform — the keyboard does not change with the window chrome. */
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||
|
||||
@@ -35,25 +31,9 @@ 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;
|
||||
/** True when macOS traffic lights overlay the app's own header row (the fused
|
||||
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
|
||||
export const IS_MAC_CHROME = IS_MAC;
|
||||
|
||||
/**
|
||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||
|
||||
@@ -32,7 +32,6 @@ export class RpcService {
|
||||
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||
private connectionsListeners = new Set<() => void>();
|
||||
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||
private windowStateListeners = new Set<(maximized: boolean) => void>();
|
||||
|
||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||
private ready: Promise<void> = Promise.resolve();
|
||||
@@ -72,9 +71,6 @@ export class RpcService {
|
||||
updateStatus: (status) => {
|
||||
for (const listener of this.updateStatusListeners) listener(status);
|
||||
},
|
||||
windowState: ({ maximized }) => {
|
||||
for (const listener of this.windowStateListeners) listener(maximized);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -136,12 +132,6 @@ export class RpcService {
|
||||
this.updateStatusListeners.add(listener);
|
||||
}
|
||||
|
||||
/** win7 skin: the frameless window's maximize state changed (derived from
|
||||
* the native resize event, so Aero Snap and Win+Up land here too). */
|
||||
onWindowState(listener: (maximized: boolean) => void): void {
|
||||
this.windowStateListeners.add(listener);
|
||||
}
|
||||
|
||||
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||
get request() {
|
||||
const rpc = this.rpc;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { RpcService } from './rpc.service';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsService {
|
||||
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
|
||||
readonly skin = signal<AppSettings['skin']>(DEFAULT_SETTINGS.skin);
|
||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
||||
@@ -51,7 +50,6 @@ export class SettingsService {
|
||||
|
||||
private applyAll(settings: AppSettings): void {
|
||||
this.locale.set(settings.locale);
|
||||
this.skin.set(settings.skin);
|
||||
this.theme.set(settings.theme);
|
||||
this.darkTone.set(settings.darkTone);
|
||||
this.accent.set(settings.accent);
|
||||
@@ -64,9 +62,6 @@ export class SettingsService {
|
||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||
this.editor.set(settings.editor);
|
||||
this.editorApp.set(settings.editorApp);
|
||||
// NO applySkin here, deliberately — see setSkin. applyAll also runs on the
|
||||
// settingsChanged push, so flipping the class here would break every OPEN
|
||||
// editor and manager window too, not just the one that changed the setting.
|
||||
this.applyTheme();
|
||||
this.applyDarkTone();
|
||||
this.applyAccent();
|
||||
@@ -80,30 +75,6 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the preference and NOTHING else — the skin is a launch-time choice.
|
||||
*
|
||||
* It deliberately does not touch the `.win7` class, which is the whole bug
|
||||
* this replaced: each skin's CSS is written against its OWN component tree's
|
||||
* markup, and the tree is fixed at bootstrap from the preload-baked
|
||||
* __APP_SKIN. Flipping the class live therefore painted 7.css onto shadlike
|
||||
* markup that has no .window.glass, no .title-bar, no menu[role=tablist] and
|
||||
* no <table> ListView — a broken window, not a reskinned one.
|
||||
*
|
||||
* A reload would not have helped either: the preload string is frozen at
|
||||
* webview construction (a persistent WebKitUserScript), and titleBarStyle /
|
||||
* transparent have no setters, so the native frame cannot change on a live
|
||||
* window. win7 -> modern without a reframe is the dangerous direction — the
|
||||
* modern tree draws no title bar, so it would land in a frameless window
|
||||
* with no way to move, minimize or close it.
|
||||
*
|
||||
* The options row shows the saved value as pending until the next start.
|
||||
*/
|
||||
setSkin(skin: AppSettings['skin']): void {
|
||||
this.skin.set(skin);
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setTheme(theme: AppSettings['theme']): void {
|
||||
this.theme.set(theme);
|
||||
this.applyTheme();
|
||||
@@ -171,12 +142,6 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
// There is deliberately no applySkin(). The `.win7` class is written once by
|
||||
// buildPreload at DOCUMENT_START for the skin this window actually booted,
|
||||
// and must never change afterwards — see setSkin for why. Every other apply*
|
||||
// below is safe to run live because it only re-points tokens, and both skins
|
||||
// consume the same token system.
|
||||
|
||||
private applyTheme(): void {
|
||||
const theme = this.theme();
|
||||
if (theme === 'system') {
|
||||
@@ -205,7 +170,7 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
/** The whole interface scale is ONE declaration: every size in the app is
|
||||
* rem, so moving the root font-size moves both skins in lockstep. 1 = the
|
||||
* rem, so moving the root font-size moves the whole UI. 1 = the
|
||||
* browser-default 16px root; delete-when-default keeps the pristine state
|
||||
* attribute-free like the other axes. buildPreload mirrors this for a
|
||||
* flash-free first paint. */
|
||||
@@ -225,7 +190,6 @@ export class SettingsService {
|
||||
protected settings(): AppSettings {
|
||||
return {
|
||||
locale: this.locale(),
|
||||
skin: this.skin(),
|
||||
theme: this.theme(),
|
||||
darkTone: this.darkTone(),
|
||||
accent: this.accent(),
|
||||
|
||||
@@ -1,840 +0,0 @@
|
||||
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to
|
||||
`.win7` (root rules) or `.win7 <sel>` (the rest), so the whole framework is
|
||||
inert until the `.win7` class is on <html> — written once by buildPreload
|
||||
for the skin this window booted, and never changed after. All 61
|
||||
icons are data URIs, so nothing needs copying into assets — and NOT the
|
||||
`7.inline.css` build, which flattens away the --w7-* vars the dark palette
|
||||
below depends on. The deep path is required: 7.css ships no `exports` map
|
||||
and Tailwind's resolver only reads `style`/`mainFields`, so a bare
|
||||
`@import "7.css"` finds no entry. */
|
||||
@import "7.css/dist/7.scoped.css" layer(skin);
|
||||
|
||||
/* ==========================================================================
|
||||
Windows 7 skin — buildPreload puts `.win7` on <html> at first paint
|
||||
==========================================================================
|
||||
|
||||
Three tiers, and the split matters:
|
||||
|
||||
- `@layer skin` (the 7.css import at the top of this file) owns the
|
||||
CONTROLS: buttons, inputs, tabs, progress bars, tree views, the Aero
|
||||
title bar. It sits ABOVE `utilities`, so a bare `button` rule in 7.css
|
||||
beats `.rounded-md` on that same button. That inversion is the whole
|
||||
trick — it restyles ~1900 inline utility classes with no template edits.
|
||||
- This unlayered section owns the BRIDGE: it re-points the app's own
|
||||
semantic tokens at Win7 values, so utilities Tailwind still controls
|
||||
(bg-card, border-border, text-muted-foreground) also come out
|
||||
Win7-correct. It adds NO new tokens on purpose — a new colour token
|
||||
would need a matching `--color-*` line in `@theme inline`.
|
||||
- `@layer skinfix` (last, above `skin`) hands individual properties BACK to
|
||||
the utilities layer with `revert-layer`, for the places where 7.css's
|
||||
Win7-dialog metrics are wrong for this app.
|
||||
|
||||
WARNING for future edits: because `skin` outranks `utilities`, any Tailwind
|
||||
utility added later to a <button>, <input>, <select>, <textarea> or <table>
|
||||
silently loses under this skin. `skinfix` is the only place to fix that.
|
||||
|
||||
Specificity notes — the blocks below must beat the token blocks above:
|
||||
:root.win7 (0,2,0) beats base :root (0,1,0)
|
||||
:root.win7[data-theme='dark'] (0,3,0) beats :root[data-theme=…] (0,2,0)
|
||||
and ties the darker-tone block (0,3,0),
|
||||
winning on source order (it is last).
|
||||
The accent formula blocks are deliberately NOT overridden: they only set
|
||||
--primary/--ring/--cursor-tint, and letting them through is what makes the
|
||||
accent picker recolour the Aero chrome — authentic, Win7 shipped exactly
|
||||
that ("Window Color" personalization). */
|
||||
|
||||
/* Segoe UI is not on macOS/Linux and is not redistributable. Noto Sans is the
|
||||
substitute 7.css itself names in its --w7-font fallback stack. Vendored
|
||||
(OFL-1.1) from @fontsource/noto-sans@5.3.0, files/noto-sans-latin-{400,700}
|
||||
-normal.woff2 -> ./fonts/, i.e. INSIDE this skin: the font exists only for
|
||||
the Win7 look, and keeping it here means the url() below stays relative to
|
||||
this file wherever the skin folder moves. (It used to sit at ui/src/fonts/
|
||||
next to the old single stylesheet — splitting this file out rebased the
|
||||
url() three directories deeper and would have silently 404'd the font.)
|
||||
Vendored rather than @imported from node_modules because Tailwind inlines
|
||||
imported CSS, which would leave @fontsource's relative url() rebased against
|
||||
the wrong directory. They live under src/ (not public/) so esbuild emits them
|
||||
as hashed assets and rewrites the url() — a public/ path is not resolvable at
|
||||
build time. */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans App';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url('./fonts/noto-sans-latin-400-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans App';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: block;
|
||||
src: url('./fonts/noto-sans-latin-700-normal.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root.win7 {
|
||||
/* Real Segoe first (Windows), bundled substitute everywhere else. */
|
||||
--w7-family: 'Segoe UI', 'SegoeUI', 'Noto Sans App', system-ui, sans-serif;
|
||||
/* ~7.css's 9pt, in REM so it follows the interface scale (the root
|
||||
font-size lever) like every other size in the app — 0.75rem = 12px at the
|
||||
16px root. Restating the `font` SHORTHAND (not just the family) is
|
||||
deliberate: 7.css applies `font: var(--w7-font)` on 15 component rules,
|
||||
and the shorthand also resets weight and line-height — pinning 400/1.4
|
||||
here stops those rules from flattening the app's `font-medium` / leading. */
|
||||
--w7-fs: 0.75rem;
|
||||
--w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family);
|
||||
|
||||
/* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction:
|
||||
there --chrome (frame strips) sits below --background (content panes);
|
||||
here the content pane is the brightest thing on screen and the frame is
|
||||
the #f0f0f0 window surface. Deliberate — that is what Explorer was. */
|
||||
--background: #ffffff;
|
||||
--chrome: #f0f0f0;
|
||||
--card: #f0f0f0;
|
||||
--foreground: #000000;
|
||||
--card-foreground: #000000;
|
||||
--muted-foreground: #6d6d6d;
|
||||
--secondary: #e8e8e8;
|
||||
--secondary-foreground: #000000;
|
||||
--muted: #e8e8e8;
|
||||
/* Win7's hover is a pale blue wash, not a grey — matches --w7-el-grad-h */
|
||||
--accent: #eaf6fd;
|
||||
--accent-foreground: #000000;
|
||||
--border: #d5d5d5;
|
||||
--input: #abadb3; /* 7.css's own text-input border */
|
||||
--destructive: #c62828;
|
||||
--destructive-foreground: #ffffff;
|
||||
--success: #06b025; /* the Win7 progress-bar green */
|
||||
|
||||
/* 0.3125rem = 5px at the 16px root — NOT 3px: `rounded-md` is 74 of the 112
|
||||
radius utilities in the templates and compiles to
|
||||
calc(var(--radius) - 0.125rem), so 5 lands it exactly on --w7-el-bdr (3px).
|
||||
--radius itself feeds `rounded-lg` (12 uses) at 5px, near the 6px window
|
||||
radius. Bare `rounded` (26 uses) is a Tailwind literal 0.25rem. Rem so the
|
||||
corners follow the interface scale with everything else. */
|
||||
--radius: 0.3125rem;
|
||||
}
|
||||
|
||||
/* Light values for every 7.css literal the skinfix rules below restate through
|
||||
vars (7.css hardcodes these outside its var system). The values are 7.css's
|
||||
own, verbatim — light must render exactly as stock. Dark overrides live in
|
||||
the duplicated dark blocks above, which is the house pattern: rules are
|
||||
written ONCE and theme through tokens. (The previous attribute-keyed dark
|
||||
rules silently missed SYSTEM-dark — the media-query path sets no attribute.) */
|
||||
:root.win7 {
|
||||
/* ListView (listview.css) */
|
||||
--w7-lv-bg: #fff;
|
||||
--w7-lv-bd: #c0c1cd;
|
||||
--w7-lv-c: #000;
|
||||
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
|
||||
--w7-lv-head-bd: #d7d7d7;
|
||||
--w7-lv-cell-bd: #eee;
|
||||
/* Selected row. Explorer painted a FLAT blue, not a gradient — 7.css reuses
|
||||
its Aero element-hover gradient for the band, which is what made the list
|
||||
look glossy. These are dedicated tokens on purpose: --w7-el-grad-h is
|
||||
consumed by buttons and tabs and must keep its gradient.
|
||||
`-idle` is the unfocused pane's band. Explorer greyed the selection out
|
||||
when the list lost focus rather than dropping it, which is exactly the
|
||||
commander convention this app already follows (see the pane-focus model
|
||||
below): the band never disappears, only its strength changes. */
|
||||
--w7-sel-bg: #cbe8f6;
|
||||
--w7-sel-bg-idle: #dedede;
|
||||
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
|
||||
outside its own var system, which is unreadable on the dark surface. */
|
||||
--w7-tab-c: #222;
|
||||
/* Menu (menu.css) */
|
||||
--w7-mn-c: #000;
|
||||
--w7-mn-bd: #0006;
|
||||
--w7-mn-stripe: inset 1px 0 #00000026, inset -1px 0 #fff;
|
||||
--w7-mn-divider: inset 0 1px #00000026, inset 0 -1px #fff;
|
||||
--w7-mn-check-ring: #b3d3f9;
|
||||
--w7-mn-check-c: #0c12a1;
|
||||
/* Glass caption streaks (window.css .window.glass>.title-bar): a literal.
|
||||
The two #fff5 corner shines wash a DARK caption silver, so dark subdues
|
||||
every white in the stack while keeping the streak structure. */
|
||||
--w7-glass-title: linear-gradient(135deg, #fff5 70px, transparent 100px), linear-gradient(225deg, #fff5 70px, transparent 100px), linear-gradient(54deg, #0002 0 4%, #6661 6% 6%, #0002 8% 10%, #0002 15% 16%, #aaa1 17% 18%, #0002 23% 24%, #bbb2 25% 26%, #0002 31% 33%, #0002 34% 34.5%, #bbb2 36% 40%, #0002 41% 41.5%, #bbb2 44% 45%, #bbb2 46% 47%, #0002 48% 49%, #0002 50% 50.5%, #0002 56% 56.5%, #bbb2 57% 63%, #0002 67% 69%, #bbb2 69.5% 70%, #0002 73.5% 74%, #bbb2 74.5% 79%, #0002 80% 84%, #aaa2 85% 86%, #0002 87%, #bbb1 90%) 0 / 100vw 100vh no-repeat fixed;
|
||||
/* Caption text (window.css .title-bar-text: black + 8-fold white halo) */
|
||||
--w7-cap-c: #000;
|
||||
--w7-cap-glow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff,
|
||||
0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff;
|
||||
}
|
||||
|
||||
/* Hand-authored dark Win7. Microsoft never shipped one — this exists so the
|
||||
theme and tone settings stay live under the skin. Only the colour-carrying
|
||||
--w7-* vars are listed: --w7-el-grad and --w7-w-grad are DERIVED from the
|
||||
values below and recolour for free, while --w7-el-grad-h/-a and
|
||||
--w7-li-bg-hl are literal hex in 7.css and so must be restated. The ~25
|
||||
geometry vars and ~20 base64 icon vars are theme-independent.
|
||||
DUPLICATED for forced-dark and system-dark, per the rule the token blocks
|
||||
above set: every dark value goes in BOTH. */
|
||||
:root.win7[data-theme='dark'] {
|
||||
--w7-surface: #2b2b2b;
|
||||
--w7-el-bg: #3a3a3a;
|
||||
--w7-el-bg-d: #333333;
|
||||
--w7-el-bg-s-1: #333333;
|
||||
--w7-el-bg-s-2: #262626;
|
||||
--w7-el-bd: #565656;
|
||||
--w7-el-bd-h: #4a8cbe;
|
||||
--w7-el-bd-a: #3f6c8c;
|
||||
--w7-el-bd-d: #3f3f3f;
|
||||
--w7-el-c: #f0f0f0;
|
||||
--w7-el-c-d: #6f6f6f;
|
||||
--w7-el-sd: inset 0 0 0 1px #ffffff14;
|
||||
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
|
||||
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
|
||||
--w7-li-bd-hl: #3f6c8c;
|
||||
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
|
||||
--w7-w-bd: #000000e6;
|
||||
/* Ares-style black glass: near-black ground; 7.css's own alpha streaks and
|
||||
highlight gradients become the gloss. Blue lives in accents (selection,
|
||||
active tab), not in the glass itself. */
|
||||
--w7-w-bg: #101013;
|
||||
--w7-tab-bg: #1c1c1e;
|
||||
--w7-title-c: #f5f5f5;
|
||||
--w7-link-c: #6fb3ff;
|
||||
/* literal restatements consumed by the skinfix rules (see :root.win7) */
|
||||
--w7-lv-bg: var(--w7-el-bg-s-2);
|
||||
--w7-lv-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-c: var(--w7-el-c);
|
||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
||||
--w7-sel-bg: #2a4a63;
|
||||
--w7-sel-bg-idle: #3a3a3a;
|
||||
--w7-tab-c: var(--w7-el-c);
|
||||
--w7-mn-c: var(--w7-el-c);
|
||||
--w7-mn-bd: var(--w7-el-bd-d);
|
||||
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
|
||||
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
|
||||
--w7-mn-check-ring: var(--w7-li-bd-hl);
|
||||
--w7-mn-check-c: var(--w7-el-c);
|
||||
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
|
||||
/* Ares: white caption on black glass, dark halo instead of white */
|
||||
--w7-cap-c: var(--w7-title-c);
|
||||
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
|
||||
--w7-link-c-h: #9acbff;
|
||||
|
||||
--background: #1f1f1f;
|
||||
--chrome: #2b2b2b;
|
||||
--card: #2b2b2b;
|
||||
--foreground: #f0f0f0;
|
||||
--card-foreground: #f0f0f0;
|
||||
--muted-foreground: #a0a0a0;
|
||||
--secondary: #383838;
|
||||
--secondary-foreground: #f0f0f0;
|
||||
--muted: #383838;
|
||||
--accent: #2f4a5c;
|
||||
--accent-foreground: #f0f0f0;
|
||||
--border: #3c3c3c;
|
||||
--input: #55585c;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root.win7:not([data-theme='light']) {
|
||||
--w7-surface: #2b2b2b;
|
||||
--w7-el-bg: #3a3a3a;
|
||||
--w7-el-bg-d: #333333;
|
||||
--w7-el-bg-s-1: #333333;
|
||||
--w7-el-bg-s-2: #262626;
|
||||
--w7-el-bd: #565656;
|
||||
--w7-el-bd-h: #4a8cbe;
|
||||
--w7-el-bd-a: #3f6c8c;
|
||||
--w7-el-bd-d: #3f3f3f;
|
||||
--w7-el-c: #f0f0f0;
|
||||
--w7-el-c-d: #6f6f6f;
|
||||
--w7-el-sd: inset 0 0 0 1px #ffffff14;
|
||||
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
|
||||
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
|
||||
--w7-li-bd-hl: #3f6c8c;
|
||||
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
|
||||
--w7-w-bd: #000000e6;
|
||||
--w7-w-bg: #101013;
|
||||
--w7-tab-bg: #1c1c1e;
|
||||
--w7-title-c: #f5f5f5;
|
||||
--w7-link-c: #6fb3ff;
|
||||
/* literal restatements consumed by the skinfix rules (see :root.win7) */
|
||||
--w7-lv-bg: var(--w7-el-bg-s-2);
|
||||
--w7-lv-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-c: var(--w7-el-c);
|
||||
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
|
||||
--w7-lv-head-bd: var(--w7-el-bd-d);
|
||||
--w7-lv-cell-bd: var(--w7-el-bd-d);
|
||||
--w7-sel-bg: #2a4a63;
|
||||
--w7-sel-bg-idle: #3a3a3a;
|
||||
--w7-tab-c: var(--w7-el-c);
|
||||
--w7-mn-c: var(--w7-el-c);
|
||||
--w7-mn-bd: var(--w7-el-bd-d);
|
||||
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
|
||||
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
|
||||
--w7-mn-check-ring: var(--w7-li-bd-hl);
|
||||
--w7-mn-check-c: var(--w7-el-c);
|
||||
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
|
||||
/* Ares: white caption on black glass, dark halo instead of white */
|
||||
--w7-cap-c: var(--w7-title-c);
|
||||
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
|
||||
--w7-link-c-h: #9acbff;
|
||||
|
||||
--background: #1f1f1f;
|
||||
--chrome: #2b2b2b;
|
||||
--card: #2b2b2b;
|
||||
--foreground: #f0f0f0;
|
||||
--card-foreground: #f0f0f0;
|
||||
--muted-foreground: #a0a0a0;
|
||||
--secondary: #383838;
|
||||
--secondary-foreground: #f0f0f0;
|
||||
--muted: #383838;
|
||||
--accent: #2f4a5c;
|
||||
--accent-foreground: #f0f0f0;
|
||||
--border: #3c3c3c;
|
||||
--input: #55585c;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark tone "darker": Aero has no meaningful true black, so rather than leave
|
||||
the Tone row a dead control the skin just goes a step deeper. Duplicated
|
||||
forced + system like everything else. */
|
||||
:root.win7[data-theme='dark'][data-dark-tone='darker'] {
|
||||
--w7-surface: #1a1a1a;
|
||||
--w7-el-bg: #262626;
|
||||
--w7-w-bg: #060607;
|
||||
--background: #121212;
|
||||
--chrome: #1a1a1a;
|
||||
--card: #1a1a1a;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root.win7:not([data-theme='light'])[data-dark-tone='darker'] {
|
||||
--w7-surface: #1a1a1a;
|
||||
--w7-el-bg: #262626;
|
||||
--w7-w-bg: #060607;
|
||||
--background: #121212;
|
||||
--chrome: #1a1a1a;
|
||||
--card: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accent -> Aero window colour, so the existing accent picker keeps meaning
|
||||
under the skin. --w7-w-bg is the one colour 7.css documents as settable.
|
||||
LIGHT ONLY: in dark the glass is Ares black regardless of accent (the accent
|
||||
keeps tinting selection etc. through --primary). These arms out-rank the
|
||||
accent rule below on specificity — without them it wins the tie on source
|
||||
order and dark glass turns accent-coloured. */
|
||||
:root.win7[data-accent] {
|
||||
--w7-w-bg: var(--primary);
|
||||
}
|
||||
:root.win7[data-theme='dark'][data-accent] {
|
||||
--w7-w-bg: #101013;
|
||||
}
|
||||
:root.win7[data-theme='dark'][data-dark-tone='darker'][data-accent] {
|
||||
--w7-w-bg: #060607;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root.win7:not([data-theme='light'])[data-accent] {
|
||||
--w7-w-bg: #101013;
|
||||
}
|
||||
:root.win7:not([data-theme='light'])[data-dark-tone='darker'][data-accent] {
|
||||
--w7-w-bg: #060607;
|
||||
}
|
||||
}
|
||||
|
||||
/* Unlayered on purpose: the html/body rule above sets font-family unlayered,
|
||||
so a layered override would lose. Family only — NOT font-size: the app's
|
||||
rem-based utilities are root-relative, and shifting the root would move
|
||||
every line box for no gain. */
|
||||
.win7 body {
|
||||
font-family: var(--w7-family);
|
||||
}
|
||||
|
||||
/* The win7 window is a FRAMELESS, natively transparent window whose only
|
||||
opaque surface is meant to be `.window` — that is the whole premise of the
|
||||
6px rounded frame, since the area outside the radius must let the desktop
|
||||
through. The unlayered `html, body { background: var(--background) }` above
|
||||
broke that premise silently: it painted an opaque rectangle behind the
|
||||
frame, so the rounded corners had an opaque ground filling them in and the
|
||||
window read as a rectangle with a drawn-on curve.
|
||||
Unlayered on purpose, same reason as the font-family rule: the html/body
|
||||
background it overrides is itself unlayered, and unlayered declarations beat
|
||||
every @layer regardless of specificity — a `skinfix` rule could not touch
|
||||
it. Scoped to .win7 so the modern skin keeps its opaque ground. */
|
||||
html.win7,
|
||||
html.win7 body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Neutralizers live in `skin`, NOT `skinfix`, and this is load-bearing:
|
||||
`revert-layer` rolls a property back to the previous LAYER, so from inside
|
||||
`skin` it lands on `utilities` (the Tailwind classes already in the
|
||||
templates) — which is the whole point. From `skinfix` it would land back on
|
||||
7.css and do nothing. Within `skin` these beat the imported rules on equal
|
||||
specificity by source order, since the import is at the top of this file. */
|
||||
@layer skin {
|
||||
/* 7.css sizes every <button> like a Win7 dialog push button
|
||||
(min-width 75px, min-height 23px, padding 0 12px). Most of this app's 84
|
||||
buttons are ~24px icon squares sized by utilities, so without this the tab
|
||||
bar wraps and the options popover overflows. Hand the BOX MODEL back to
|
||||
the utilities layer and keep 7.css's paint. The title bar's own controls
|
||||
are matched by a MORE specific 7.css selector (.title-bar-controls button)
|
||||
and so keep their Aero glyph metrics untouched. */
|
||||
.win7 button {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: revert-layer;
|
||||
text-align: revert-layer;
|
||||
color: revert-layer;
|
||||
}
|
||||
|
||||
/* 7.css hardcodes #fff / #abadb3 / #222 on form controls and tables, outside
|
||||
its own var system. Reverting routes them back through the bridged tokens
|
||||
above, which is what makes dark mode nearly free. */
|
||||
.win7 input[type='text'],
|
||||
.win7 input[type='password'],
|
||||
.win7 input[type='number'],
|
||||
.win7 input[type='search'],
|
||||
.win7 textarea {
|
||||
background-color: revert-layer;
|
||||
border-color: revert-layer;
|
||||
}
|
||||
.win7 table {
|
||||
background-color: revert-layer;
|
||||
color: revert-layer;
|
||||
}
|
||||
|
||||
/* Win7 toolbar buttons are FLAT with a hover glow, not Aero slabs —
|
||||
Explorer's command bar worked exactly that way. `w7-toolbar` marks the
|
||||
three header rows; a plain `.title-bar` sibling is deliberately NOT one of
|
||||
them, so its controls keep the real Aero treatment. `w7-flat` is the
|
||||
per-button escape hatch. Both classes are inert under the modern skin.
|
||||
`:not([role='tab'])` is load-bearing: the workspace strip IS a w7-toolbar,
|
||||
so without the exemption this rule would strip the border, gradient and
|
||||
both pseudo-elements off every tab and leave 7.css's tab component drawing
|
||||
nothing. Tabs want the full Aero slab; the +/Connections/options buttons
|
||||
beside them want the flat treatment.
|
||||
Only `background` stays here — it is the one property that needs to land
|
||||
on `utilities` via revert-layer. The literals that suppress the Aero slab
|
||||
moved to `skinfix`, because 7.css restates border-color at
|
||||
`.win7 button:not(:disabled):not(:hover)` = (0,3,1), which outranks
|
||||
`.win7 .w7-flat` = (0,2,0). The toolbar arm only ever won that fight by
|
||||
tying on specificity and landing later in the same layer — an accident,
|
||||
and one w7-flat did not share: it rendered a bordered slab regardless. */
|
||||
.win7 .w7-toolbar button:not([role='tab']),
|
||||
.win7 .w7-flat {
|
||||
background: revert-layer;
|
||||
}
|
||||
.win7 .w7-toolbar button:not([role='tab'])::before,
|
||||
.win7 .w7-toolbar button:not([role='tab'])::after,
|
||||
.win7 .w7-flat::before,
|
||||
.win7 .w7-flat::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Above `skin`, for rules that must state a value rather than defer to a
|
||||
neighbouring layer. */
|
||||
@layer skinfix {
|
||||
/* ---- Scale bridge: re-express 7.css's px metrics in rem ----
|
||||
7.css sizes everything in px (370 of them, zero rem) and its --w7-font
|
||||
shorthand reaches only 15 rules' TYPOGRAPHY — the boxes stay frozen. Under
|
||||
the interface scale (root font-size lever) win7 text would grow inside
|
||||
23px buttons, 14px table rows and a 15px-line-height caption. Every value
|
||||
below is 7.css's own, converted at the 16px root (23px -> 1.4375rem …),
|
||||
so scale 1.0 renders pixel-identical to stock and every other scale keeps
|
||||
the boxes proportional to the text.
|
||||
Deliberately NOT converted: 1px hairlines and focus rects (the tr border
|
||||
that stops selection shifting rows, .is-cursor's 1px dotted outline, menu
|
||||
bevels, the frame's inset glass ring) — those stay crisp at any scale. */
|
||||
|
||||
/* 7.css's own size vars — defined on .win7 in px; rem lands on the same
|
||||
pixels at scale 1. Covers checkbox/radio/slider/tooltip/treeview/menu
|
||||
gutters and both window metrics in one shot. */
|
||||
.win7 {
|
||||
--w7-cb-size: 0.875rem; /* 14px */
|
||||
--w7-rd-size: 0.875rem;
|
||||
--w7-rdd-size: 0.5rem; /* 8px */
|
||||
--w7-rdl-space: 0.375rem; /* 6px */
|
||||
--w7-sdt-w: 0.625rem; /* 10px */
|
||||
--w7-sdt-h: 1.125rem; /* 18px */
|
||||
--w7-blt-size: 1.125rem;
|
||||
--w7-tvb-size: 0.5rem;
|
||||
--w7-tv-left: 1.25rem; /* 20px */
|
||||
--w7-clc-size: 0.3125rem; /* 5px */
|
||||
--w7-lvc-size: 0.3125rem;
|
||||
--w7-mn-left: 1.75rem; /* 28px */
|
||||
--w7-el-bdr: 0.1875rem; /* 3px */
|
||||
--w7-w-space: 0.375rem; /* 6px */
|
||||
--w7-w-bdr: 0.375rem;
|
||||
}
|
||||
|
||||
/* The hardcoded box metrics the vars do not reach, restated in rem; skinfix
|
||||
outranks skin by layer, so these win over 7.css.
|
||||
NO blanket `.win7 button` rule here, deliberately: the `skin`-layer
|
||||
neutralizer strips 7.css's 75x23 metrics off every button (most are icon
|
||||
squares sized by utilities), and a skinfix restatement would outrank that
|
||||
neutralizer by LAYER and re-inflate all of them. The only buttons that
|
||||
legitimately wear Win7 push-button metrics are .w7-btn (opted in below)
|
||||
and the title-bar controls (which beat the neutralizer on specificity
|
||||
inside `skin` and are restated here). */
|
||||
.win7 input[type='email'],
|
||||
.win7 input[type='number'],
|
||||
.win7 input[type='password'],
|
||||
.win7 input[type='search'],
|
||||
.win7 input[type='text'],
|
||||
.win7 input[type='url'] {
|
||||
height: 1.4375rem;
|
||||
}
|
||||
.win7 input[type='email'],
|
||||
.win7 input[type='number'],
|
||||
.win7 input[type='password'],
|
||||
.win7 input[type='search'],
|
||||
.win7 input[type='text'],
|
||||
.win7 input[type='url'],
|
||||
.win7 textarea {
|
||||
padding: 0.1875rem 0.25rem 0.3125rem; /* 3px 4px 5px */
|
||||
}
|
||||
.win7 table > thead > tr > * {
|
||||
height: 1.375rem; /* 22px */
|
||||
padding: 0 0.5rem; /* 0 8px */
|
||||
}
|
||||
.win7 table > tbody > tr > * {
|
||||
height: 0.875rem; /* 14px */
|
||||
padding: 0.125rem 0.5rem; /* 2px 8px */
|
||||
}
|
||||
.win7 .title-bar-text {
|
||||
line-height: 0.9375rem; /* 15px */
|
||||
}
|
||||
.win7 .title-bar-controls button {
|
||||
min-height: 1.1875rem; /* 19px */
|
||||
min-width: 1.8125rem; /* 29px */
|
||||
padding: 0;
|
||||
}
|
||||
/* BOTH of 7.css's wide-close selectors, not just .is-close: the app's title
|
||||
bars key the buttons on aria-label, and the base rule above outranks the
|
||||
`skin`-layer variant BY LAYER — restating only one arm shrank the close
|
||||
button from 48px to 29px (caught by the harness pixel diff). */
|
||||
.win7 .title-bar-controls button.is-close,
|
||||
.win7 .title-bar-controls button[aria-label='Close'] {
|
||||
min-width: 3rem; /* 48px */
|
||||
}
|
||||
.win7 ul[role='menu'] {
|
||||
min-width: 9.375rem; /* 150px */
|
||||
padding: 0.125rem; /* 2px */
|
||||
}
|
||||
.win7 ul[role='menu'] > [role='menuitem'] > a,
|
||||
.win7 ul[role='menu'] > [role='menuitem'] > button,
|
||||
.win7 ul[role='menu'] > [role='menuitem'] > label {
|
||||
padding: 0.25rem 0.625rem 0.25rem 2rem; /* 4px 10px 4px 32px */
|
||||
}
|
||||
.win7 menu[role='tablist'] button {
|
||||
padding: 0.125rem 0.375rem; /* 2px 6px */
|
||||
}
|
||||
/* The raised active tab: 7.css gives it padding-bottom 4px at (0,2,2) inside
|
||||
`skin`, but the shorthand above outranks that BY LAYER and would flatten
|
||||
the raise — so the variant is restated here too. */
|
||||
.win7 menu[role='tablist'] button[aria-selected='true'] {
|
||||
padding-bottom: 0.25rem; /* 4px */
|
||||
}
|
||||
/* The 10pt checkmark glyph in menu checkables — one of five pt sizes in
|
||||
7.css that bypass --w7-font entirely. 10pt = 13.333px ≈ 0.8333rem. */
|
||||
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
|
||||
font-size: 0.8333rem;
|
||||
}
|
||||
|
||||
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
|
||||
buttons where they are right. Same rem values as the button rule above —
|
||||
this class exists to undo the `skin`-layer neutralizer, not to differ. */
|
||||
.win7 .w7-btn {
|
||||
min-width: 4.6875rem;
|
||||
min-height: 1.4375rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
/* ---- ListView (file panels) ----
|
||||
7.css's listview.css hardcodes light literals (#fff, #c0c1cd, #eee and the
|
||||
white header gradient) instead of --w7-* vars, so unlike the rest of the
|
||||
skin it does NOT follow the hand-authored dark palette — the header labels
|
||||
came out invisible, dark on dark. These restate the same rules in dark
|
||||
terms; light keeps 7.css's own values untouched.
|
||||
|
||||
CRITICAL: the base surface goes on `table`, NEVER on `tbody > tr > *`.
|
||||
7.css paints the selection band on the ROW, and a cell background paints
|
||||
over a row background — setting one erased the selection highlight
|
||||
entirely in dark mode. */
|
||||
.win7 table {
|
||||
background-color: var(--w7-lv-bg);
|
||||
border-color: var(--w7-lv-bd);
|
||||
color: var(--w7-lv-c);
|
||||
}
|
||||
.win7 table > thead > tr > * {
|
||||
background: var(--w7-lv-head-grad);
|
||||
border-color: var(--w7-lv-head-bd);
|
||||
color: var(--w7-lv-c);
|
||||
}
|
||||
.win7 table > tbody > tr > :not(:last-child) {
|
||||
border-right-color: var(--w7-lv-cell-bd);
|
||||
}
|
||||
/* 7.css drops the cell separators inside a selected row; the rule above is
|
||||
more specific than its, so restore that behaviour explicitly. */
|
||||
.win7 table > tbody > tr.highlighted > :not(:last-child) {
|
||||
border-right: none;
|
||||
}
|
||||
.win7 table > tbody > tr.highlighted {
|
||||
background: var(--w7-sel-bg-idle);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ---- Window frame ----
|
||||
The native window is transparent (chromeOptions in src/bun/windows.ts), and
|
||||
nothing but .window may paint an opaque surface — that is what lets the 6px
|
||||
corner radius actually be transparent at the corners. That invariant is NOT
|
||||
free: html/body paint an opaque background unlayered, and had to be reset
|
||||
to transparent for .win7 (see the rule above @layer skin). Anything else
|
||||
that paints full-bleed will fill the corners the same way.
|
||||
NO drop shadow, by request: 7.css's `2px 2px 10px 1px #000000b3` needed a
|
||||
margin gutter to fall into and still read heavy against a real desktop, so
|
||||
the frame keeps only the inset 1px glass highlight from that shorthand
|
||||
(dropping the whole box-shadow would lose the highlight too — it lives in
|
||||
the same declaration). No shadow -> no gutter -> full-bleed 100vh.
|
||||
Keyed on `w7-frame` — the app hook the three window ROOTS carry — never on
|
||||
bare .window: on any other .window (dialogs, demos) this sizing is
|
||||
nonsense and 7.css's own shadow is fine. */
|
||||
.win7 .window.w7-frame {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
box-shadow: inset 0 0 0 1px #fffa;
|
||||
}
|
||||
|
||||
/* A maximized Win7 window has SQUARE corners — the radius only exists to let
|
||||
the desktop show past a floating frame, and a rounded maximized window
|
||||
leaves four notches of desktop in the screen corners.
|
||||
Read off the title bar's own `is-maximized` (title-bar.ts publishes it on
|
||||
its host) with :has(), so the frame reacts without a second windowState
|
||||
subscription. .title-bar is a DIRECT child of .window — app-title-bar
|
||||
carries the class on its host element, which is why `>` holds here.
|
||||
The radius is declared three times in 7.css — on .window and on both the
|
||||
::before tint and the ::after glass layer — so all three must be zeroed or
|
||||
a rounded pseudo-element still cuts the corner off the square frame. */
|
||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized),
|
||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::before,
|
||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::after {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* STOPGAP — the synthesized resize handles (window-resize/window-resize.ts,
|
||||
which carries the removal checklist). A maximized window has no edges to
|
||||
grab, so the overlay goes away entirely while it is maximized, and comes
|
||||
back on restore.
|
||||
Read off the title bar's own `is-maximized` with :has(), exactly like the
|
||||
corner-squaring rule above — that is what keeps this component free of a
|
||||
second `windowState` subscription (RpcService's push registries have no
|
||||
off(), so every subscription is permanent). */
|
||||
.win7 .window.w7-frame:has(> .title-bar.is-maximized) .w7-resize {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hovering the close button paints 7.css's red glow ::before
|
||||
(box-shadow: 0 0 7px 3px #e68e75), and box-shadows of CHILDREN are not
|
||||
clipped by the parent's border or radius — the caption buttons sit flush
|
||||
against the window's top edge (border-top: 0), so the glow spilled ABOVE
|
||||
the frame: a red tint across the top border and a red halo floating on the
|
||||
desktop past it (scripts/hover-probe.ts renders the forced-hover state and
|
||||
pixel-scans for exactly this; it caught 100 red pixels above the edge).
|
||||
Clip the title bar's children to its own box on top/left/right — the glow
|
||||
now dies at the frame border, which is where the real Win7 DWM stopped it
|
||||
too — while the bottom stays open (-1rem) so the glow's bleed toward the
|
||||
caption below survives; that part is authentic.
|
||||
clip-path also clips HIT-TESTING, but only for content outside the region:
|
||||
the title-bar's own box (the drag region) is entirely inside it.
|
||||
Scoped to w7-frame: demo/harness windows keep stock 7.css behaviour. */
|
||||
.win7 .window.w7-frame > .title-bar {
|
||||
clip-path: inset(0 0 -1rem 0);
|
||||
}
|
||||
|
||||
/* 7.css's glass layer is `backdrop-filter: blur(4px)` on .window::after —
|
||||
but CSS cannot sample the DESKTOP behind a transparent native window
|
||||
(there is no backdrop to read), and WebKitGTK paints the unsampleable
|
||||
layer BLACK: every see-through part of the glass — the frame ring, the
|
||||
area around the title-bar buttons — composited over black instead of over
|
||||
the desktop. With it off, the real desktop shows through the glass tint,
|
||||
which is closer to Aero than a blur we cannot have. (True blur-behind
|
||||
needs compositor support; GNOME offers none to X11/XWayland clients.) */
|
||||
.win7 .window.glass::after {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* Constant row geometry. 7.css gives ONLY tr.highlighted a 1px border, and
|
||||
the table is border-collapse: collapse — so a selected row physically grew
|
||||
1px at top and bottom and shoved every row below it down by 2px as the
|
||||
cursor moved. Every row reserves that border instead, transparent when not
|
||||
selected, so selecting changes colour and nothing else.
|
||||
The border stays TRANSPARENT even when selected: it exists purely to
|
||||
reserve space. 7.css outlines the selected row (#aaddfa) on top of its
|
||||
fill, which stacked a second edge inside the band and, with the cursor
|
||||
row's dotted rect, drew three concentric outlines around one row. The fill
|
||||
paints under the transparent border (background-clip defaults to
|
||||
border-box), so the band still covers the full row. */
|
||||
.win7 table > tbody > tr {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* Pane-focus model (commander convention, matching Win7 Explorer):
|
||||
the CURSOR ROW always carries the selection band in BOTH panels —
|
||||
tr.highlighted covers "selected via Space" AND "cursor row" (the template
|
||||
ORs them). What changes with pane focus is the band's strength, never
|
||||
whether it exists:
|
||||
- focused pane -> the flat blue selection (below)
|
||||
- unfocused pane -> the grey idle band, which is what Explorer drew
|
||||
when the list lost focus — faint by design, not a bug.
|
||||
The dotted focus rectangle is NOT the focus signal; it is layered on top
|
||||
of the band in the focused pane only, the way Win7 drew the keyboard
|
||||
focus rect over the selected item.
|
||||
FLAT, and only a fill: 7.css bands the row with --w7-el-grad-h, its Aero
|
||||
element-hover ramp, which reads as a glossy button rather than a file
|
||||
selection, and outlines it as well. One solid colour, no edge — the
|
||||
dotted cursor rect is then the only line on the row, which is what makes
|
||||
it legible as the cursor. */
|
||||
.win7 table.is-active > tbody > tr.highlighted {
|
||||
background: var(--w7-sel-bg);
|
||||
}
|
||||
|
||||
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
|
||||
around every cell instead — visibly wrong. */
|
||||
.win7 table > tbody > tr.is-cursor {
|
||||
outline: 1px dotted var(--w7-el-c);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* ---- Menu (context menu) ----
|
||||
The menu itself is pure 7.css — ul[role=menu] > li[role=menuitem], see
|
||||
ui/src/app/context-menu.ts for the exact contract. (A previous iteration
|
||||
claimed "7.css has no menu component" and hand-built a lookalike here;
|
||||
false — the markup was one level off and its selectors never matched.)
|
||||
Only menu.css's hardcoded LIGHT literals need restating for the dark
|
||||
palette, same as the ListView above: the black `color:initial`, the
|
||||
white bevel halves of the gutter stripe and dividers, and the checked-box
|
||||
ring (#b3d3f9). */
|
||||
.win7 ul[role='menu'] {
|
||||
color: var(--w7-mn-c);
|
||||
border-color: var(--w7-mn-bd);
|
||||
}
|
||||
|
||||
/* THE POSITIONING ONE — this is why right-click looked dead under the skin.
|
||||
menu.css declares `position: relative` on ul[role=menu] (it needs it for
|
||||
the gutter stripe's ::before and for submenu anchoring), and `skin`
|
||||
outranks `utilities`, so Tailwind's `.fixed` on the app's menu lost. Its
|
||||
[style.left/top.px] then became FLOW offsets from the menu's static
|
||||
position — the last child of an h-screen flex column — and every menu
|
||||
rendered below the viewport, unreachable behind `overflow: hidden`. The
|
||||
backdrop is a plain <div>, which 7.css does not match, so it stayed truly
|
||||
fixed and swallowed the next click: "right click does nothing, then the
|
||||
app ignores me once".
|
||||
Scoped to the app menu's own marker class, never blanket: 7.css's own
|
||||
relative positioning must survive for any other ul[role=menu].
|
||||
Regression test: scripts/menu-probe.ts (a resting pixel diff cannot see a
|
||||
positioning bug — nothing about the idle window changes). */
|
||||
.win7 ul[role='menu'].w7-ctx {
|
||||
position: fixed;
|
||||
}
|
||||
.win7 ul[role='menu']:before {
|
||||
box-shadow: var(--w7-mn-stripe);
|
||||
}
|
||||
.win7 ul [role='menuitem'].has-divider:after {
|
||||
box-shadow: var(--w7-mn-divider);
|
||||
}
|
||||
.win7 ul [role='menuitem'] > input[type] + label:before {
|
||||
box-shadow: 0 0 0 1px var(--w7-mn-check-ring);
|
||||
}
|
||||
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
|
||||
color: var(--w7-mn-check-c);
|
||||
}
|
||||
|
||||
/* Menu items: flat highlight, and no Aero afterglow trailing the pointer.
|
||||
Menu items ARE <button>s, so button.css reaches them with three effects
|
||||
that do not belong on a menu:
|
||||
(1) the highlight is 7.css's translucent wash
|
||||
`linear-gradient(#fff9, #e6ecf5cc 90%, #fffc)` + an #aaddfa edge — the
|
||||
same gradient the ListView selection above just dropped;
|
||||
(2) `button::before` is a blue glow overlay whose opacity fades out over
|
||||
a FULL SECOND (`button:not(:hover):before { transition: opacity 1s
|
||||
linear }`). menu.css sets `content: none` on the :HOVER arm only, so
|
||||
the pseudo reappears the instant you leave an item and then fades 1→0
|
||||
— a ghost dragging behind the pointer. Killing `content` in EVERY
|
||||
state is what removes it; suppressing the transition alone would not,
|
||||
because the glow itself is legitimate on real push buttons;
|
||||
(3) `button:focus` runs `pulse-anim` infinitely, and this menu moves REAL
|
||||
focus between items for keyboard nav — so a focused item would breathe.
|
||||
Scoped to the app's own menu (w7-ctx): 7.css's demo menus keep stock Aero. */
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::before,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::after {
|
||||
content: none;
|
||||
}
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:hover,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus-visible,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:hover,
|
||||
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:focus-visible {
|
||||
background: var(--w7-sel-bg);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ---- Flat buttons (toolbars + the w7-flat escape hatch) ----
|
||||
The paint-suppression half of the rule in `skin` (see there for the split).
|
||||
It lives in `skinfix` because 7.css restates border-color at (0,3,1) via
|
||||
`.win7 button:not(:disabled):not(:hover)` and again on :hover, which no
|
||||
reasonable `.w7-flat` selector outranks — layer order settles it instead
|
||||
of a specificity arms race. */
|
||||
.win7 .w7-toolbar button:not([role='tab']),
|
||||
.win7 .w7-flat {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ---- Tabs (workspace strip) ----
|
||||
7.css owns the tab CHROME; these restore the tab CONTENT layout it breaks.
|
||||
`menu[role=tablist] button` sets `display: block` at (0,2,1), which
|
||||
outranks Tailwind's `.flex` in `utilities` — so the icon and the label
|
||||
rendered jammed together with no gap and no truncation. Restate the flex
|
||||
box here (skinfix outranks skin) and leave every painted property alone.
|
||||
The gap matches the gap-1.5 the template asks for. */
|
||||
.win7 menu[role='tablist'] button[role='tab'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* tabs.css hardcodes `color: #222` on every tablist button — a light-mode
|
||||
literal outside 7.css's var system, which left the labels near-invisible
|
||||
on the dark surface. Same failure and same remedy as the ListView and menu
|
||||
literals above: route it through a token that both dark blocks override. */
|
||||
.win7 menu[role='tablist'] button {
|
||||
color: var(--w7-tab-c);
|
||||
}
|
||||
|
||||
/* The workspace strip scrolls horizontally when tabs overflow. Setting
|
||||
`overflow-x: auto` forces the computed `overflow-y` from visible to auto
|
||||
as well (CSS will not pair visible with a non-visible axis), so the strip
|
||||
clips vertically — and 7.css raises the ACTIVE tab with `margin-top: -2px`,
|
||||
which put its top border outside the scroll box and silently deleted it.
|
||||
Reserve those 2px as padding so the pulled-up tab lands inside the box.
|
||||
7.css already reserves the matching 3px on the left (padding-left: 3px)
|
||||
for the same tab's -3px left margin; it just never anticipated a scroller.
|
||||
Verified by pixel scan: the active tab's top edge must be #8e8f8f, not a
|
||||
straight jump from the toolbar grey to the tab fill. */
|
||||
.win7 .w7-toolbar menu[role='tablist'] {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.win7 .window.glass > .title-bar {
|
||||
background: var(--w7-glass-title);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Caption text: 7.css hardcodes black + a white halo; in dark that becomes
|
||||
the Ares treatment (white caption, dark halo) through the same vars. */
|
||||
.win7 .title-bar-text {
|
||||
color: var(--w7-cap-c);
|
||||
text-shadow: var(--w7-cap-glow);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<!-- .window.glass is 7.css's Aero frame (radius, 1px border, inset highlight,
|
||||
drop shadow, backdrop blur); .active is what un-greys the title bar and its
|
||||
control glyphs — every .title-bar-controls rule is keyed on it. -->
|
||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
||||
@if (!rpc.available) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ i18n.t('app.notConnectedBody') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- the window is frameless, so this IS the chrome -->
|
||||
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
|
||||
|
||||
<!-- 7.css's .window-body: the recessed inner panel, with the 6px inset and
|
||||
its own border that give a Win7 window its depth. -->
|
||||
<div class="window-body flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<app-tab-bar />
|
||||
|
||||
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
||||
<app-file-panel side="left" />
|
||||
<app-file-panel side="right" />
|
||||
</main>
|
||||
|
||||
<app-transfer-bar />
|
||||
<app-status-bar />
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- modal block: the connection-manager window owns the app until closed -->
|
||||
@if (managerOpen()) {
|
||||
<div
|
||||
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
|
||||
(mousedown)="focusManager()"
|
||||
>
|
||||
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
|
||||
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
|
||||
<button
|
||||
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ i18n.t('app.showConnections') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- dialogs -->
|
||||
@if (dialogs.prompt(); as req) {
|
||||
<app-prompt-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.confirm(); as req) {
|
||||
<app-confirm-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.chmod(); as req) {
|
||||
<app-chmod-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.overwrite(); as req) {
|
||||
<app-overwrite-dialog [request]="req" />
|
||||
}
|
||||
|
||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
||||
own handles. Delete with the rest of the hack — checklist in
|
||||
window-resize/window-resize.ts. -->
|
||||
<app-window-resize />
|
||||
|
||||
<app-context-menu />
|
||||
<app-toasts />
|
||||
</div>
|
||||
@@ -1,307 +0,0 @@
|
||||
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 { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { StatusBar } from './status-bar/status-bar';
|
||||
import { TabBar } from './tab-bar/tab-bar';
|
||||
import { TitleBar } from './title-bar/title-bar';
|
||||
import { ToastService } from '@core/toast.service';
|
||||
import { Toasts } from './toasts/toasts';
|
||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||
import { WindowResize } from './window-resize/window-resize';
|
||||
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,
|
||||
TitleBar,
|
||||
ContextMenu,
|
||||
WindowResize,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
host: {
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
// Aero greys an inactive window's title bar and controls. The webview gets
|
||||
// DOM focus/blur when the native window's focus changes, so this needs no
|
||||
// RPC — see the `active` binding on .window in app.html.
|
||||
'(window:focus)': 'windowFocused.set(true)',
|
||||
'(window:blur)': 'windowFocused.set(false)',
|
||||
},
|
||||
})
|
||||
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 shortcuts = inject(ShortcutsService);
|
||||
private readonly updater = inject(UpdaterService);
|
||||
|
||||
/** The connection-manager window is open — this window is modal-blocked. */
|
||||
readonly managerOpen = signal(false);
|
||||
|
||||
/** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */
|
||||
readonly windowFocused = signal(document.hasFocus());
|
||||
|
||||
/** Shown in the win7 title bar (no native titlebar to carry it). */
|
||||
readonly appName = BRANDING.appName;
|
||||
|
||||
/**
|
||||
* win7 title bar's X. Closing the main window quits, and the app-level
|
||||
* before-quit guard (dirty editors + running transfers) still runs — it is
|
||||
* the one close path electrobun lets us cancel.
|
||||
*/
|
||||
closeWindow(): void {
|
||||
void this.rpc.request.windowClose({});
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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('tab:')) {
|
||||
this.handleTabAction(action.slice('tab:'.length));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
||||
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
||||
* the panels themselves. */
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
||||
const match = this.shortcuts.resolve(event, 'main');
|
||||
if (!match) return;
|
||||
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
|
||||
// the tab shortcuts keep firing there, as they always did
|
||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
switch (match.id) {
|
||||
case 'new-tab':
|
||||
this.panels.newWorkspace();
|
||||
break;
|
||||
case 'close-tab':
|
||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||
break;
|
||||
case 'cycle-tab':
|
||||
this.panels.cycleWorkspace(1);
|
||||
break;
|
||||
case 'cycle-tab-back':
|
||||
this.panels.cycleWorkspace(-1);
|
||||
break;
|
||||
case 'go-to-tab':
|
||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
||||
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
||||
break;
|
||||
case 'scale-up':
|
||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
break;
|
||||
case 'scale-down':
|
||||
this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
break;
|
||||
case 'scale-reset':
|
||||
this.settings.setUiScale(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||
/**
|
||||
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||
* because the right-clicked tab is not necessarily the active one — nothing
|
||||
* here may fall back to activeWorkspaceId.
|
||||
*/
|
||||
private handleTabAction(action: string): void {
|
||||
if (action === 'new') {
|
||||
this.panels.newWorkspace();
|
||||
return;
|
||||
}
|
||||
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
|
||||
if (!parsed) return;
|
||||
const id = Number(parsed[2]);
|
||||
switch (parsed[1]) {
|
||||
case 'close':
|
||||
this.panels.closeWorkspace(id);
|
||||
break;
|
||||
case 'close-others':
|
||||
this.panels.closeOtherWorkspaces(id);
|
||||
break;
|
||||
case 'close-right':
|
||||
this.panels.closeWorkspacesToRight(id);
|
||||
break;
|
||||
case 'disconnect':
|
||||
void this.panels.disconnectWorkspace(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
||||
<!-- Frameless window. Fixed-size modal, so Close only. -->
|
||||
<app-title-bar
|
||||
[title]="i18n.t('connMgr.title')"
|
||||
[resizable]="false"
|
||||
(closeRequested)="close()"
|
||||
/>
|
||||
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
|
||||
height this row collapses onto the icon and the screen reads as broken. -->
|
||||
<header
|
||||
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="importWinScp()"
|
||||
[disabled]="importing()"
|
||||
[title]="i18n.t('connMgr.importWinscpHint')"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
@if (importing()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
} @else {
|
||||
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
|
||||
}
|
||||
{{ i18n.t('connMgr.importWinscp') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- stacks + connections list -->
|
||||
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
|
||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<button
|
||||
(click)="newConnection()"
|
||||
[title]="i18n.t('connMgr.newConnectionHint')"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="newStack()"
|
||||
[title]="i18n.t('connMgr.newStackHint')"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
|
||||
<!-- stacks -->
|
||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
||||
<div
|
||||
(dragover)="onDragOver($event, group.stack.id)"
|
||||
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
|
||||
(drop)="onDrop($event, group.stack.id)"
|
||||
class="rounded-md"
|
||||
[class.bg-primary/10]="dropTarget() === group.stack.id"
|
||||
[class.ring-1]="dropTarget() === group.stack.id"
|
||||
[class.ring-inset]="dropTarget() === group.stack.id"
|
||||
[class.ring-primary]="dropTarget() === group.stack.id"
|
||||
>
|
||||
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
||||
<button
|
||||
(click)="toggleStack(group.stack)"
|
||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
|
||||
size="0.8rem"
|
||||
/>
|
||||
</button>
|
||||
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||
{{ group.stack.name }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1"></div>
|
||||
<!-- count and actions swap on hover: they never share the space,
|
||||
so a long stack name can't collide with the buttons -->
|
||||
<span
|
||||
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
||||
>
|
||||
{{ group.connections.length }}
|
||||
</span>
|
||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<button
|
||||
(click)="renameStack(group.stack)"
|
||||
[title]="i18n.t('connMgr.renameStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaEdit" size="0.75rem" />
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteStack(group.stack)"
|
||||
[title]="i18n.t('connMgr.deleteStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.75rem" />
|
||||
</button>
|
||||
@if (group.connections.length) {
|
||||
<button
|
||||
(click)="openStack(group.stack.id)"
|
||||
[title]="i18n.t('connMgr.openStack')"
|
||||
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!group.stack.collapsed) {
|
||||
@for (connection of group.connections; track connection.id) {
|
||||
<button
|
||||
(click)="edit(connection)"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart($event, connection)"
|
||||
(dragend)="onDragEnd()"
|
||||
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
|
||||
[class.bg-accent]="draft()?.id === connection.id"
|
||||
[class.opacity-50]="draggedId() === connection.id"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- loose connections -->
|
||||
<div
|
||||
(dragover)="onDragOver($event, 'loose')"
|
||||
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
|
||||
(drop)="onDrop($event, null)"
|
||||
class="min-h-8 rounded-md pb-1"
|
||||
[class.bg-primary/10]="dropTarget() === 'loose'"
|
||||
[class.ring-1]="dropTarget() === 'loose'"
|
||||
[class.ring-inset]="dropTarget() === 'loose'"
|
||||
[class.ring-primary]="dropTarget() === 'loose'"
|
||||
>
|
||||
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
||||
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||
{{ i18n.t('connMgr.connectionsHeader') }}
|
||||
</div>
|
||||
}
|
||||
@for (connection of store.grouped().loose; track connection.id) {
|
||||
<button
|
||||
(click)="edit(connection)"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart($event, connection)"
|
||||
(dragend)="onDragEnd()"
|
||||
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
||||
[class.bg-accent]="draft()?.id === connection.id"
|
||||
[class.opacity-50]="draggedId() === connection.id"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!store.connections().length && !store.stacks().length) {
|
||||
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
||||
{{ i18n.t('connMgr.noneTitle') }}
|
||||
</p>
|
||||
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
||||
{{ i18n.t('connMgr.noneBody') }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- edit form -->
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
@if (draft(); as d) {
|
||||
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
||||
<input
|
||||
[value]="d.name"
|
||||
(input)="patch('name', $any($event.target).value)"
|
||||
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
|
||||
<input
|
||||
[value]="d.host"
|
||||
(input)="patch('host', $any($event.target).value)"
|
||||
placeholder="192.168.1.10"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
|
||||
<input
|
||||
type="number"
|
||||
[value]="d.port"
|
||||
(input)="patch('port', +$any($event.target).value)"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
|
||||
<select
|
||||
(change)="patch('stackId', $any($event.target).value || null)"
|
||||
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<!-- [selected] per option: binding [value] on the <select> runs
|
||||
before the options render and silently shows "(none)" -->
|
||||
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
|
||||
@for (stack of store.stacks(); track stack.id) {
|
||||
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
||||
{{ stack.name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
|
||||
<input
|
||||
[value]="d.username"
|
||||
(input)="patch('username', $any($event.target).value)"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
|
||||
<input
|
||||
[value]="d.initialPath"
|
||||
(input)="patch('initialPath', $any($event.target).value)"
|
||||
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="col-span-2 flex items-center gap-4 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="authKind"
|
||||
[checked]="d.authKind === 'password'"
|
||||
(change)="patch('authKind', 'password')"
|
||||
class="accent-primary"
|
||||
/>
|
||||
{{ i18n.t('connMgr.authPassword') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="authKind"
|
||||
[checked]="d.authKind === 'key'"
|
||||
(change)="patch('authKind', 'key')"
|
||||
class="accent-primary"
|
||||
/>
|
||||
{{ i18n.t('connMgr.authKey') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (d.authKind === 'password') {
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
|
||||
<input
|
||||
type="password"
|
||||
[value]="d.password"
|
||||
(input)="patch('password', $any($event.target).value)"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
} @else {
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
[value]="d.privateKeyPath"
|
||||
(input)="patch('privateKeyPath', $any($event.target).value)"
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
spellcheck="false"
|
||||
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
(click)="pickKeyFile()"
|
||||
class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.choose') }}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
|
||||
<input
|
||||
type="password"
|
||||
[value]="d.passphrase"
|
||||
(input)="patch('passphrase', $any($event.target).value)"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
|
||||
@if (moreOpen()) {
|
||||
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
|
||||
<input
|
||||
[value]="d.localDir"
|
||||
(input)="patch('localDir', $any($event.target).value)"
|
||||
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span class="text-[0.625rem] text-muted-foreground/80">
|
||||
{{ i18n.t('connMgr.localDirHint') }}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
|
||||
<button
|
||||
(click)="deleteCurrent()"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="moreOpen.set(!moreOpen())"
|
||||
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="save()"
|
||||
[disabled]="!valid()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="saveAndConnect()"
|
||||
[disabled]="!valid() || busy()"
|
||||
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
@if (busy()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
}
|
||||
{{ i18n.t('common.connect') }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid flex-1 place-items-center">
|
||||
<div class="text-center">
|
||||
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
{{ i18n.t('connMgr.selectPrompt') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
|
||||
@if (dialogs.prompt(); as req) {
|
||||
<app-prompt-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.confirm(); as req) {
|
||||
<app-confirm-dialog [request]="req" />
|
||||
}
|
||||
|
||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
||||
own handles. Delete with the rest of the hack — checklist in
|
||||
window-resize/window-resize.ts. -->
|
||||
<app-window-resize />
|
||||
|
||||
<app-toasts />
|
||||
</div>
|
||||
@@ -1,291 +0,0 @@
|
||||
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 } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { TitleBar } from '../title-bar/title-bar';
|
||||
import { Toasts } from '../toasts/toasts';
|
||||
import { WindowResize } from '../window-resize/window-resize';
|
||||
|
||||
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, TitleBar, WindowResize],
|
||||
templateUrl: './connection-manager.html',
|
||||
host: {
|
||||
'(window:keydown.escape)': 'onEscape()',
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
// Aero desaturates an unfocused window's chrome — see .window.active.
|
||||
'(window:focus)': 'windowFocused.set(true)',
|
||||
'(window:blur)': 'windowFocused.set(false)',
|
||||
},
|
||||
})
|
||||
export class ConnectionManager {
|
||||
readonly i18n = inject(I18nService);
|
||||
|
||||
/** Drives `.window.active` on this window's Aero frame. */
|
||||
readonly windowFocused = signal(document.hasFocus());
|
||||
readonly store = inject(ConnectionsService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
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);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
|
||||
readonly draft = signal<Draft | null>(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<string | null>(null);
|
||||
/** Highlighted drop target: a stack id, 'loose', or null. */
|
||||
readonly dropTarget = signal<string | null>(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<K extends keyof Draft>(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();
|
||||
}
|
||||
|
||||
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
const match = this.shortcuts.resolve(event, 'manager');
|
||||
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
||||
}
|
||||
|
||||
blockContextMenu(event: Event): void {
|
||||
blockNativeContextMenu(event);
|
||||
}
|
||||
|
||||
async save(): Promise<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const res = await this.rpc.request.pickKeyFile({});
|
||||
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
|
||||
}
|
||||
|
||||
async importWinScp(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
import { remPx } from '@core/metrics';
|
||||
|
||||
/** Rough per-item metrics in REM, used to size the menu and decide whether it
|
||||
* must flip. Rem because the interface scale is a root font-size lever: the
|
||||
* old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box
|
||||
* stayed 200px while the text inside grew with the scale and overflowed.
|
||||
* Values mirror the skinfix restatement of 7.css's menu metrics in 7like.css
|
||||
* (item padding 0.25rem x2 + the ~0.9375rem line). */
|
||||
const ITEM_H_REM = 1.5; // 24px @16
|
||||
const DIVIDER_H_REM = 0.4375; // 7px @16: 2px bevel + 3px/2px margins
|
||||
const MENU_W_REM = 12.5; // 200px @16
|
||||
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||
|
||||
/** One rendered <li>: separators collapse onto the item BEFORE them. */
|
||||
interface MenuRow {
|
||||
item: MenuItem;
|
||||
hasDivider: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The markup is 7.css's menu contract EXACTLY — `ul[role=menu] >
|
||||
* li[role=menuitem] > button` for actions, `> input[type=checkbox] + label`
|
||||
* for the checkable column toggles, and dividers as `.has-divider` on the
|
||||
* PRECEDING item (7.css draws them as an ::after bevel, not as a node). Get
|
||||
* any of that wrong and 7.css silently styles nothing: an earlier version
|
||||
* rendered div > button, concluded "7.css has no menu component", and
|
||||
* hand-built a lookalike. It does have one; the selectors just never matched.
|
||||
*
|
||||
* `w7-ctx` is OURS, not 7.css's: `ul[role=menu]` carries `position: relative`
|
||||
* in 7.css (for its gutter stripe and submenus), and the skin layer outranks
|
||||
* Tailwind's `.fixed` — so the menu laid out in flow, at the bottom of the
|
||||
* h-screen frame, off the viewport. The class scopes a `position: fixed`
|
||||
* restatement in 7like.css to THIS menu; see scripts/menu-probe.ts.
|
||||
*
|
||||
* Keyboard follows the platform: arrows move REAL focus between items (7.css
|
||||
* styles :focus-visible identically to :hover), Enter/Space on a button
|
||||
* activate it natively; only labels need a synthetic click.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
template: `
|
||||
@if (menu.request(); as req) {
|
||||
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
|
||||
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
|
||||
<ul
|
||||
#menuEl
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
(keydown)="onKeydown($event)"
|
||||
[style.left.px]="position().x"
|
||||
[style.top.px]="position().y"
|
||||
[style.width.rem]="menuWidthRem"
|
||||
class="w7-ctx fixed z-50 outline-none"
|
||||
>
|
||||
@for (row of rows(); track $index) {
|
||||
<!-- aria-disabled on the LI, not the button: that is where 7.css
|
||||
hangs its disabled state (opacity + pointer-events: none). -->
|
||||
<li
|
||||
role="menuitem"
|
||||
[class.has-divider]="row.hasDivider"
|
||||
[attr.aria-disabled]="row.item.disabled ? true : null"
|
||||
[title]="row.item.tooltip ?? ''"
|
||||
>
|
||||
@if (row.item.checked !== undefined) {
|
||||
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
|
||||
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
|
||||
{{ row.item.label }}
|
||||
</label>
|
||||
} @else {
|
||||
<button type="button" (click)="pick(row.item)">
|
||||
{{ row.item.label }}
|
||||
@if (row.item.accelerator) {
|
||||
<span>{{ row.item.accelerator }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
`,
|
||||
// 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<ElementRef<HTMLUListElement>>('menuEl');
|
||||
|
||||
/** Rendered width AND math input are the same rem value, so the flip point
|
||||
* and the box can never disagree — the shadlike menu's old bug. */
|
||||
protected readonly menuWidthRem = MENU_W_REM;
|
||||
|
||||
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
|
||||
readonly rows = computed<MenuRow[]>(() => {
|
||||
const items = this.menu.request()?.items ?? [];
|
||||
const rows: MenuRow[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type === 'separator') {
|
||||
if (rows.length) rows[rows.length - 1].hasDivider = true;
|
||||
} else {
|
||||
rows.push({ item, hasDivider: false });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
||||
* UI metrics convert through the live root font-size (remPx); the click
|
||||
* point and window.inner* are both viewport CSS px already. */
|
||||
readonly position = computed(() => {
|
||||
const req = this.menu.request();
|
||||
if (!req) return { x: 0, y: 0 };
|
||||
const rows = this.rows();
|
||||
const menuW = remPx(MENU_W_REM);
|
||||
const height =
|
||||
rows.length * remPx(ITEM_H_REM) +
|
||||
rows.filter((r) => r.hasDivider).length * remPx(DIVIDER_H_REM) +
|
||||
remPx(0.25); // ul padding 0.125rem x2
|
||||
const x =
|
||||
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : 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.focus.bump();
|
||||
}
|
||||
|
||||
pick(item: MenuItem, event?: Event): void {
|
||||
// labels: don't let the click also toggle the real checkbox — the fresh
|
||||
// `checked` state comes back through settings when the menu reopens
|
||||
event?.preventDefault();
|
||||
// 7.css already blocks the pointer on [aria-disabled]; this covers the
|
||||
// keyboard path, which routes through focus and never consults CSS
|
||||
if (item.disabled) return;
|
||||
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 {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
this.step(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
this.step(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
// buttons activate natively from their own focus; labels don't, so
|
||||
// synthesize the click (which routes through pick via the template)
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLLabelElement && this.menuEl()?.nativeElement.contains(active)) {
|
||||
event.preventDefault();
|
||||
active.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move REAL focus by one item, wrapping. 7.css styles :focus-visible the
|
||||
* same as :hover, and keyboard-initiated programmatic focus counts as
|
||||
* focus-visible, so the highlight comes from the framework for free.
|
||||
*/
|
||||
private step(delta: number): void {
|
||||
const root = this.menuEl()?.nativeElement;
|
||||
if (!root) return;
|
||||
// disabled items are skipped, not merely unclickable: pointer-events:none
|
||||
// does not make an element unfocusable, so arrows would still land on them
|
||||
const targets = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
'li:not([aria-disabled]) > button, li:not([aria-disabled]) > label',
|
||||
),
|
||||
);
|
||||
if (!targets.length) return;
|
||||
const current = targets.indexOf(document.activeElement as HTMLElement);
|
||||
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
|
||||
targets[next].focus();
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<div
|
||||
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown.escape)="cancel()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
|
||||
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
|
||||
|
||||
<table class="mt-3 w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-muted-foreground">
|
||||
<th class="pb-1 text-left font-normal"></th>
|
||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
|
||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
|
||||
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (role of roles; track role; let r = $index) {
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
|
||||
@for (perm of perms; track perm; let p = $index) {
|
||||
<td class="py-1 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="hasBit(r, p)"
|
||||
(change)="toggleBit(r, p)"
|
||||
class="size-3.5 accent-primary"
|
||||
/>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
|
||||
<input
|
||||
[value]="octal()"
|
||||
(input)="setOctal($any($event.target).value)"
|
||||
maxlength="4"
|
||||
spellcheck="false"
|
||||
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
(click)="cancel()"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="submit()"
|
||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ i18n.t('common.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ChmodDialog {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly request = input.required<ChmodRequest>();
|
||||
|
||||
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<number | null>(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();
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<div
|
||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown.escape)="cancel()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
(click)="cancel()"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
#confirmBtn
|
||||
(click)="submit()"
|
||||
[class]="
|
||||
request().destructive
|
||||
? 'w7-btn rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
|
||||
"
|
||||
>
|
||||
{{ request().confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ConfirmDialog {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly request = input.required<ConfirmRequest>();
|
||||
|
||||
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('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();
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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: `
|
||||
<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() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.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-[0.625rem] font-medium text-muted-foreground"
|
||||
>
|
||||
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
@if (isDir()) {
|
||||
{{ i18n.t('dialogs.overwriteDirBody') }}
|
||||
} @else {
|
||||
{{ i18n.t('dialogs.overwriteFileBody') }}
|
||||
}
|
||||
</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-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{{ i18n.t('dialogs.' + side.label) }}
|
||||
</span>
|
||||
@if (side.newer) {
|
||||
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
|
||||
{{ i18n.t('dialogs.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() ? '—' : i18n.size(side.entry.size) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
|
||||
{{ i18n.date(side.entry.mtime) }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
(click)="pick('cancel')"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ i18n.t('common.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"
|
||||
>
|
||||
{{ i18n.t('dialogs.skipAll') }}
|
||||
</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() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
|
||||
</button>
|
||||
<div class="h-4 w-px bg-border"></div>
|
||||
}
|
||||
<button
|
||||
(click)="pick('skip')"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('dialogs.skip') }}
|
||||
</button>
|
||||
<button
|
||||
#primaryBtn
|
||||
(click)="pick('overwrite')"
|
||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OverwriteDialog {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly request = input.required<OverwriteRequest>();
|
||||
|
||||
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly focus = inject(FocusService);
|
||||
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.focus.bump();
|
||||
req.resolve(choice);
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<div
|
||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||
<input
|
||||
#box
|
||||
[value]="value()"
|
||||
(input)="value.set($any($event.target).value)"
|
||||
(keydown.enter)="submit()"
|
||||
(keydown.escape)="cancel()"
|
||||
[placeholder]="request().placeholder ?? ''"
|
||||
spellcheck="false"
|
||||
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
(click)="cancel()"
|
||||
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="submit()"
|
||||
[disabled]="!value().trim()"
|
||||
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{{ request().confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class PromptDialog {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly request = input.required<PromptRequest>();
|
||||
readonly value = signal('');
|
||||
|
||||
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('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();
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
|
||||
@if (!ctx) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ i18n.t('editor.noFileBody') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- The window is frameless. The X runs the SAME dirty prompt as ⌘W and
|
||||
the Close button — never a raw window close. -->
|
||||
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
|
||||
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
|
||||
height this row collapses onto the icon and the screen reads as broken. -->
|
||||
<header
|
||||
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
|
||||
>
|
||||
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
||||
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
||||
</h1>
|
||||
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="toggleWrap()"
|
||||
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
||||
class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||
[class.bg-accent]="wrap()"
|
||||
[class.text-foreground]="wrap()"
|
||||
[class.text-muted-foreground]="!wrap()"
|
||||
>
|
||||
{{ i18n.t('editor.wrap') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="openFind()"
|
||||
[title]="i18n.t('editor.findHint', { mod: modLabel })"
|
||||
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="mynaSearch" size="0.85rem" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (findOpen()) {
|
||||
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
|
||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
#findBox
|
||||
[value]="findQuery()"
|
||||
(input)="onFindInput($any($event.target).value)"
|
||||
(keydown)="onFindKeydown($event)"
|
||||
[placeholder]="i18n.t('editor.findPlaceholder')"
|
||||
spellcheck="false"
|
||||
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<span
|
||||
class="text-[0.6875rem] tabular-nums"
|
||||
[class.text-destructive]="findQuery() && !matches().length"
|
||||
[class.text-muted-foreground]="!findQuery() || matches().length"
|
||||
>
|
||||
{{ countLabel() }}
|
||||
</span>
|
||||
<button
|
||||
(click)="findStep(-1)"
|
||||
[title]="i18n.t('editor.prevMatch')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaChevronUp" size="0.8rem" />
|
||||
</button>
|
||||
<button
|
||||
(click)="findStep(1)"
|
||||
[title]="i18n.t('editor.nextMatch')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaChevronDown" size="0.8rem" />
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="closeFind()"
|
||||
[title]="i18n.t('editor.closeHint')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.8rem" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sessionDead()) {
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
|
||||
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
|
||||
</div>
|
||||
} @else if (loadError(); as error) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
|
||||
<button
|
||||
(click)="discardAndClose()"
|
||||
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('editor.closeWindow') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- gutter and textarea share font/leading/vertical padding — that's
|
||||
the whole alignment contract; keep the classes in lockstep -->
|
||||
@if (!wrap()) {
|
||||
<div
|
||||
#gutter
|
||||
aria-hidden="true"
|
||||
[style.width.ch]="gutterDigits() + 3"
|
||||
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
|
||||
>{{ gutterText() }}</div>
|
||||
}
|
||||
<textarea
|
||||
#box
|
||||
[value]="text()"
|
||||
(input)="onInput($any($event.target).value)"
|
||||
(scroll)="onScroll()"
|
||||
spellcheck="false"
|
||||
[attr.wrap]="wrap() ? 'soft' : 'off'"
|
||||
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
|
||||
[class.whitespace-pre]="!wrap()"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
|
||||
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
||||
<span class="text-[0.6875rem] text-muted-foreground">·</span>
|
||||
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
|
||||
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="requestClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('editor.close') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="save()"
|
||||
[disabled]="saving() || !dirty()"
|
||||
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
@if (saving()) {
|
||||
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||
}
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
|
||||
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
|
||||
dialog pulls in PanelsService, which must not boot in this window. -->
|
||||
@if (closePrompt()) {
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
||||
<div
|
||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">
|
||||
{{ i18n.t('editor.saveChangesBody') }}
|
||||
</p>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
(click)="discardAndClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('editor.dontSave') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="cancelClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="saveAndClose()"
|
||||
[disabled]="saving()"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{{ i18n.t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
|
||||
own handles. Delete with the rest of the hack — checklist in
|
||||
window-resize/window-resize.ts. -->
|
||||
<app-window-resize />
|
||||
|
||||
<app-toasts />
|
||||
</div>
|
||||
@@ -1,389 +0,0 @@
|
||||
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, MOD_LABEL } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { ToastService } from '@core/toast.service';
|
||||
import { TitleBar } from '../title-bar/title-bar';
|
||||
import { Toasts } from '../toasts/toasts';
|
||||
import { WindowResize } from '../window-resize/window-resize';
|
||||
|
||||
/** 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, TitleBar, WindowResize],
|
||||
templateUrl: './editor-window.html',
|
||||
host: {
|
||||
'(window:keydown)': 'onKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
// Aero desaturates an unfocused window's chrome — see .window.active.
|
||||
// blur already had to flush the draft; both run from the one binding
|
||||
// (a duplicated key is a TS1117 build error, not a merge).
|
||||
'(window:focus)': 'windowFocused.set(true)',
|
||||
'(window:blur)': 'flushDraft(); windowFocused.set(false)',
|
||||
},
|
||||
})
|
||||
export class EditorWindow {
|
||||
readonly i18n = inject(I18nService);
|
||||
|
||||
/** Drives `.window.active` on this window's Aero frame. */
|
||||
readonly windowFocused = signal(document.hasFocus());
|
||||
readonly ctx = EDITOR_CONTEXT;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly altLabel = ALT_LABEL;
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(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<ElementRef<HTMLTextAreaElement>>('box');
|
||||
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
||||
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
||||
private cachedCharWidth = 0;
|
||||
private cachedCharFont = '';
|
||||
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 readonly shortcuts = inject(ShortcutsService);
|
||||
|
||||
private draftTimer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
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();
|
||||
// Fallback derives from the element's own font size, not a hardcoded 20 —
|
||||
// the interface scale moves the root font-size, so any px literal here
|
||||
// would be a 16px-root assumption that miscentres at every other scale.
|
||||
const style = getComputedStyle(el);
|
||||
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
|
||||
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, cached per computed FONT rather than per window:
|
||||
* the interface scale changes the root font-size live, and a cache measured
|
||||
* once would keep positioning finds with the old advance width. Keying on
|
||||
* the font string makes it self-invalidating under any font change. */
|
||||
private charWidth(el: HTMLTextAreaElement): number {
|
||||
const font = getComputedStyle(el).font;
|
||||
if (font !== this.cachedCharFont) {
|
||||
const canvas = this.document.createElement('canvas').getContext('2d')!;
|
||||
canvas.font = font;
|
||||
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
|
||||
this.cachedCharFont = font;
|
||||
}
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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 {
|
||||
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
|
||||
// Escape and Tab stay below because their meaning is THIS window's state
|
||||
// (close prompt up? find bar open? caret in the textarea?), not a binding.
|
||||
const match = this.shortcuts.resolve(event, 'editor');
|
||||
if (match) {
|
||||
// scale keys must not fire while typing — but the editor's own chords
|
||||
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
|
||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
switch (match.id) {
|
||||
case 'save':
|
||||
void this.save();
|
||||
break;
|
||||
case 'find':
|
||||
this.openFind();
|
||||
break;
|
||||
case 'close-window':
|
||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
|
||||
// and bun routes it back as editorCloseRequest
|
||||
this.requestClose();
|
||||
break;
|
||||
case 'toggle-wrap':
|
||||
this.toggleWrap();
|
||||
break;
|
||||
case 'scale-up':
|
||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
break;
|
||||
case 'scale-down':
|
||||
this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
break;
|
||||
case 'scale-reset':
|
||||
this.settings.setUiScale(1);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
<!-- session strip.
|
||||
w7-toolbar: these are Explorer command-bar verbs, not dialog push buttons,
|
||||
so under the win7 skin they go flat with a hover glow instead of wearing
|
||||
the full Aero slab — the same treatment the Connections button gets from
|
||||
the tab-bar row. -->
|
||||
<div class="w7-toolbar relative flex items-center gap-1.5 border-b border-border px-2 py-1">
|
||||
<button
|
||||
(click)="toggleMenu()"
|
||||
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
||||
[title]="i18n.t('panel.choosePanel')"
|
||||
>
|
||||
@if (panel().connecting()) {
|
||||
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
|
||||
} @else {
|
||||
<ng-icon
|
||||
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
|
||||
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
||||
size="var(--action-icon)"
|
||||
/>
|
||||
}
|
||||
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
|
||||
<ng-icon
|
||||
name="mynaChevronDown"
|
||||
class="shrink-0 text-muted-foreground"
|
||||
size="calc(var(--action-icon) - 0.1rem)"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
||||
labels when the panel is wide enough (container query). -->
|
||||
<button
|
||||
(click)="copyToOther()"
|
||||
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
|
||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
|
||||
>
|
||||
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="run('edit')"
|
||||
[title]="i18n.t('panel.editFile')"
|
||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFileText" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="run('rename')"
|
||||
[title]="i18n.t('panel.rename')"
|
||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaEdit" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="run('mkdir')"
|
||||
[title]="i18n.t('panel.newFolder')"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
|
||||
</button>
|
||||
<button
|
||||
(click)="run('createFile')"
|
||||
[title]="i18n.t('panel.newFile')"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
||||
</button>
|
||||
@if (chmodAvailable()) {
|
||||
<button
|
||||
(click)="run('chmod')"
|
||||
[title]="i18n.t('panel.permissions')"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
(click)="run('delete')"
|
||||
[title]="i18n.t('panel.deleteHint')"
|
||||
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
|
||||
</button>
|
||||
|
||||
@if (menuOpen()) {
|
||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
||||
<div
|
||||
class="has-scrollbar absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
||||
>
|
||||
<button
|
||||
(click)="pickLocal()"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1">{{ i18n.t('common.local') }}</span>
|
||||
@if (!isRemote()) {
|
||||
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Windows drive roots (empty list elsewhere) -->
|
||||
@for (drive of drives(); track drive) {
|
||||
<button
|
||||
(click)="pickDrive(drive)"
|
||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 font-mono">{{ drive }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
||||
<div class="my-1 border-t border-border"></div>
|
||||
}
|
||||
@for (connection of connections.grouped().loose; track connection.id) {
|
||||
<button
|
||||
(click)="pickConnection(connection)"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
}
|
||||
@for (group of connections.grouped().stacks; track group.stack.id) {
|
||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{{ group.stack.name }}
|
||||
</div>
|
||||
@for (connection of group.connections; track connection.id) {
|
||||
<button
|
||||
(click)="pickConnection(connection)"
|
||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (connections.sshHosts().length) {
|
||||
<div class="my-1 border-t border-border"></div>
|
||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
~/.ssh/config
|
||||
</div>
|
||||
@for (host of connections.sshHosts(); track host.name) {
|
||||
<button
|
||||
(click)="pickSshHost(host)"
|
||||
[disabled]="host.unsupported"
|
||||
[title]="sshHostTitle(host)"
|
||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 truncate">{{ host.name }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- path bar. Also w7-toolbar: the up/refresh controls sit next to the action
|
||||
row, so leaving them as Aero slabs would read as arbitrary. The class only
|
||||
targets buttons — the path <input> keeps its 7.css textbox treatment. -->
|
||||
<div class="w7-toolbar flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<button
|
||||
(click)="panel().up()"
|
||||
[disabled]="blocked()"
|
||||
[title]="i18n.t('panel.parentDir')"
|
||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ng-icon name="mynaArrowUp" size="1rem" />
|
||||
</button>
|
||||
<input
|
||||
[value]="pathDraft()"
|
||||
(input)="pathDraft.set($any($event.target).value)"
|
||||
(keydown.enter)="commitPath()"
|
||||
(keydown.escape)="resetPath()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
[disabled]="blocked()"
|
||||
spellcheck="false"
|
||||
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
(click)="panel().refresh()"
|
||||
[disabled]="blocked()"
|
||||
[title]="i18n.t('panel.refresh')"
|
||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
@if (panel().loading()) {
|
||||
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
||||
} @else {
|
||||
<ng-icon name="mynaRefresh" size="1rem" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Column headers live INSIDE the table now (7.css makes thead sticky), so
|
||||
there is no separate header row here — see the ListView below. -->
|
||||
|
||||
<!-- type-to-filter bar (appears while a query is active) -->
|
||||
@if (panel().filter(); as query) {
|
||||
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
||||
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
||||
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
|
||||
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
||||
</span>
|
||||
<button
|
||||
(click)="clearFilter()"
|
||||
[title]="i18n.t('panel.clearFilter')"
|
||||
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.8rem" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- file list -->
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div
|
||||
#list
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
(mousedown)="activate()"
|
||||
(contextmenu)="listContextMenu($event)"
|
||||
class="has-scrollbar h-full overflow-y-auto outline-none"
|
||||
>
|
||||
@if (panel().connecting()) {
|
||||
<div class="panel-fade-in grid h-full place-items-center">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
|
||||
</span>
|
||||
<button
|
||||
(click)="cancelConnect()"
|
||||
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- 7.css ListView: a plain <table>. The header is <thead> (7.css makes it
|
||||
position:sticky, which is why it moved in here), the sorted column is
|
||||
.highlighted.indicator[.up], and the selection band is tr.highlighted.
|
||||
Widths come from <colgroup> because 7.css sets table-layout:fixed.
|
||||
`w-full` is Tailwind's job and safe here: 7.css never declares `width` on
|
||||
table, so the `skin` layer has nothing to outrank `utilities` with.
|
||||
Without it the fixed layout shrinks to content and leaves a dead grey
|
||||
gutter down the right of the pane; with it the unsized first <col>
|
||||
absorbs the slack, which is what makes Name the elastic column. -->
|
||||
<table class="w-full" [class.is-active]="isActive()">
|
||||
<colgroup>
|
||||
<col />
|
||||
@for (col of visibleColumns(); track col.key) {
|
||||
<col [style.width]="col.width" />
|
||||
}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr (contextmenu)="headerContextMenu($event)">
|
||||
<th
|
||||
(click)="panel().toggleSort('name')"
|
||||
[class.highlighted]="panel().sortKey() === 'name'"
|
||||
[class.indicator]="panel().sortKey() === 'name'"
|
||||
[class.up]="panel().sortKey() === 'name' && panel().sortAsc()"
|
||||
>
|
||||
{{ i18n.t('columns.name') }}
|
||||
</th>
|
||||
@for (col of visibleColumns(); track col.key) {
|
||||
<th
|
||||
(click)="panel().toggleSort(col.key)"
|
||||
[class.highlighted]="panel().sortKey() === col.key"
|
||||
[class.indicator]="panel().sortKey() === col.key"
|
||||
[class.up]="panel().sortKey() === col.key && panel().sortAsc()"
|
||||
[style.text-align]="col.key === 'size' ? 'right' : 'left'"
|
||||
>
|
||||
{{ i18n.t('columns.' + col.key) }}
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
||||
<tr
|
||||
[attr.data-index]="i"
|
||||
(click)="rowClick($event, i)"
|
||||
(dblclick)="openAt(i)"
|
||||
(contextmenu)="rowContextMenu($event, i)"
|
||||
[class.highlighted]="isSelected(entry) || i === panel().cursor()"
|
||||
[class.is-cursor]="i === panel().cursor() && isActive()"
|
||||
>
|
||||
<td>
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
|
||||
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
||||
</span>
|
||||
</td>
|
||||
@for (col of visibleColumns(); track col.key) {
|
||||
@switch (col.key) {
|
||||
@case ('size') {
|
||||
<td style="text-align: right">{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}</td>
|
||||
}
|
||||
@case ('mtime') {
|
||||
<td>{{ formatDate(entry.mtime) }}</td>
|
||||
}
|
||||
@case ('kind') {
|
||||
<!-- fileKind is localized in file-panel.ts via i18n -->
|
||||
<td>{{ fileKind(entry) }}</td>
|
||||
}
|
||||
@case ('rights') {
|
||||
<td>{{ entry.name === '..' ? '' : formatMode(entry.mode) }}</td>
|
||||
}
|
||||
@case ('owner') {
|
||||
<td>{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}</td>
|
||||
}
|
||||
@case ('group') {
|
||||
<td>{{ entry.name === '..' ? '' : (entry.group ?? '—') }}</td>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
|
||||
{{ i18n.t('panel.emptyDir') }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (panel().filter() && filterMatches() === 0) {
|
||||
<tr>
|
||||
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
|
||||
{{ i18n.t('panel.noMatches') }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
@if (panel().slowLoad() && !panel().connecting()) {
|
||||
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
|
||||
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,355 +0,0 @@
|
||||
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 { ShortcutsService } from '@core/shortcuts.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<Side>();
|
||||
|
||||
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 shortcuts = inject(ShortcutsService);
|
||||
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<string[]>([]);
|
||||
/** 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<ElementRef<HTMLDivElement>>('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));
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 (this.shortcuts.isMod(event)) {
|
||||
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 (this.shortcuts.isMod(event) && 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' });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,17 +0,0 @@
|
||||
import { App } from './app';
|
||||
import { ConnectionManager } from './connection-manager/connection-manager';
|
||||
import { EditorWindow } from './editor-window/editor-window';
|
||||
|
||||
/**
|
||||
* The three window roots of the Windows 7 skin, keyed by APP_MODE.
|
||||
*
|
||||
* Every skin exports this same shape, so main.ts selects a tree by skin and
|
||||
* then a root by mode without knowing anything about either skin's internals.
|
||||
* All three roots declare `selector: 'app-root'` — the single <app-root> in
|
||||
* index.html hosts whichever one gets bootstrapped.
|
||||
*/
|
||||
export const SEVEN_ROOTS = {
|
||||
main: App,
|
||||
manager: ConnectionManager,
|
||||
editor: EditorWindow,
|
||||
} as const;
|
||||
@@ -1,81 +0,0 @@
|
||||
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: `
|
||||
<div
|
||||
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
|
||||
>
|
||||
<!-- transfers summary; click toggles the detailed list -->
|
||||
<!-- w7-flat: this is a text toggle, not a push button. Without it 7.css
|
||||
paints the full Aero slab and the status line reads as a stray
|
||||
bordered button sitting in the status bar. -->
|
||||
<button
|
||||
(click)="transfers.expanded.set(!transfers.expanded())"
|
||||
class="w7-flat flex min-w-0 items-center gap-1.5 hover:text-foreground"
|
||||
[title]="i18n.t('transfer.showDetails')"
|
||||
>
|
||||
@if (transfers.currentJob(); as job) {
|
||||
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
|
||||
<span class="truncate">
|
||||
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
|
||||
@if (transfers.queuedCount(); as queued) {
|
||||
· {{ i18n.t('transfer.queued', { count: queued }) }}
|
||||
}
|
||||
</span>
|
||||
} @else if (transfers.failedJobs().length; as failed) {
|
||||
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
|
||||
<span class="text-destructive">
|
||||
{{ i18n.t('transfer.failed', { count: failed }) }}
|
||||
</span>
|
||||
} @else if (transfers.hasJobs()) {
|
||||
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
|
||||
<span>{{ i18n.t('transfer.finished') }}</span>
|
||||
} @else {
|
||||
<span>{{ i18n.t('transfer.ready') }}</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- active panel info -->
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<ng-icon
|
||||
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'"
|
||||
size="0.8rem"
|
||||
[class.text-primary]="activePanel().sessionId() !== 'local'"
|
||||
/>
|
||||
<span class="max-w-56 truncate">
|
||||
{{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }}
|
||||
</span>
|
||||
<span>· {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }}</span>
|
||||
@if (activePanel().selected().size; as count) {
|
||||
<span>· {{ i18n.t('transfer.selected', { count }) }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
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())}` : '';
|
||||
}
|
||||
}
|
||||
@@ -1,621 +0,0 @@
|
||||
import { Component, ElementRef, computed, 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 { ContextMenuService } from '@core/context-menu.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||
import { APP_SKIN, MOD_LABEL } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { UpdaterService } from '@core/updater.service';
|
||||
|
||||
const SKINS = ['modern', 'win7'] as const;
|
||||
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.<value>.
|
||||
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 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: `
|
||||
<!-- The workspace strip, directly under the title bar. Draggable so the
|
||||
empty stretch between the tabs and the right-hand tools still moves the
|
||||
frameless window. ONE class attribute: a second one is silently dropped
|
||||
by the template parser, which is exactly how this row lost its height. -->
|
||||
<div
|
||||
class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5 electrobun-webkit-app-region-drag"
|
||||
>
|
||||
<!-- 7.css's real tab component. The contract is exact: menu[role=tablist]
|
||||
for the strip and a button[aria-selected] per tab — 7.css then draws
|
||||
the joined inactive tabs and raises the active one (white fill, no
|
||||
bottom border, pulled 2px down over the row's border so it reads as
|
||||
joined to the content). Everything the hand-rolled version painted
|
||||
(bg-accent, the absolutely-positioned bg-primary underline) is gone:
|
||||
it was a lookalike competing with the framework.
|
||||
NOTE the selector is "menu[role=tablist] button" — a DESCENDANT, so
|
||||
any button in here becomes a tab. The "+" and the right-hand tools
|
||||
deliberately live OUTSIDE this element.
|
||||
(No backticks in this template: it is a TS template literal.) -->
|
||||
<menu
|
||||
#tabScroll
|
||||
role="tablist"
|
||||
class="flex min-w-0 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
|
||||
<!-- No ✕ inside the tab: real Win7 tab controls had none, and a button
|
||||
nested in a button is invalid HTML. Closing is Ctrl/Cmd+W or
|
||||
middle-click (auxclick), so a pointer path survives. -->
|
||||
<button
|
||||
role="tab"
|
||||
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
|
||||
[attr.data-tab-id]="ws.id"
|
||||
(click)="panels.setActiveWorkspace(ws.id)"
|
||||
(auxclick)="onAuxClick($event, ws.id)"
|
||||
(contextmenu)="onTabContextMenu($event, ws, i)"
|
||||
class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5 electrobun-webkit-app-region-no-drag"
|
||||
[class.opacity-50]="draggedId() === ws.id"
|
||||
[style.box-shadow]="dropIndicatorShadow(i)"
|
||||
[title]="panels.workspaceName(ws)"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart($event, ws.id)"
|
||||
(dragover)="onDragOver($event, i)"
|
||||
(drop)="onDrop($event, i)"
|
||||
(dragend)="onDragEnd()"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
|
||||
size="0.85rem"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
|
||||
</button>
|
||||
}
|
||||
</menu>
|
||||
<button
|
||||
(click)="panels.newWorkspace()"
|
||||
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
|
||||
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=".8rem" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- auto-update tool: one accent pill that announces the update, becomes its
|
||||
own progress bar while downloading, then turns into "Restart to apply" -->
|
||||
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
|
||||
<button
|
||||
(click)="onUpdateClick()"
|
||||
[disabled]="updater.state() === 'downloading'"
|
||||
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
||||
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
||||
>
|
||||
@if (updater.state() === 'downloading') {
|
||||
<span
|
||||
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
|
||||
[style.width.%]="updater.progress()"
|
||||
></span>
|
||||
}
|
||||
<span class="relative flex items-center gap-1.5">
|
||||
@switch (updater.state()) {
|
||||
@case ('available') {
|
||||
<ng-icon name="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
|
||||
}
|
||||
@case ('downloading') {
|
||||
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
|
||||
}
|
||||
@case ('ready') {
|
||||
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- connection manager (own native window) -->
|
||||
<button
|
||||
(click)="openManager()"
|
||||
[title]="i18n.t('tabBar.manageConnections')"
|
||||
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
|
||||
{{ i18n.t('tabBar.connections') }}
|
||||
</button>
|
||||
|
||||
<!-- global options -->
|
||||
<button
|
||||
(click)="settingsOpen.set(!settingsOpen())"
|
||||
[title]="i18n.t('tabBar.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="mynaDots" size="1.5rem" />
|
||||
</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-70 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">{{ i18n.t('options.title') }}</h3>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
|
||||
<select
|
||||
(change)="settings.setLocale($any($event.target).value)"
|
||||
class="h-7 rounded-md border border-input bg-background px-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="system" [selected]="settings.locale() === 'system'">
|
||||
{{ i18n.t('options.system') }}
|
||||
</option>
|
||||
@for (loc of localeOptions; track loc) {
|
||||
<option [value]="loc" [selected]="settings.locale() === loc">
|
||||
{{ localeLabels[loc] }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
|
||||
(ui/src/app/skins/) and the native frame, and neither can change
|
||||
on a live window — the tree is bootstrapped from the preload-baked
|
||||
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
|
||||
constructor options. Clicking only saves; the running UI is left
|
||||
alone. Highlight the SAVED value so the click registers, and show
|
||||
the pending hint whenever it differs from the booted skin. -->
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (s of skins; track s) {
|
||||
<button
|
||||
(click)="settings.setSkin(s)"
|
||||
[title]="i18n.t('options.skin.restartHint')"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.skin() === s"
|
||||
[class.text-foreground]="settings.skin() === s"
|
||||
[class.text-muted-foreground]="settings.skin() !== s"
|
||||
>
|
||||
{{ i18n.t('options.skin.' + s) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (skinPending()) {
|
||||
<p class="mt-1 text-right text-[0.625rem] text-muted-foreground">
|
||||
{{ i18n.t('options.skin.restartHint') }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</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-[0.6875rem]"
|
||||
[class.bg-accent]="settings.theme() === theme"
|
||||
[class.text-foreground]="settings.theme() === theme"
|
||||
[class.text-muted-foreground]="settings.theme() !== theme"
|
||||
>
|
||||
{{ i18n.t('options.theme.' + theme) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (settings.effectiveDark()) {
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.tone.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (tone of darkTones; track tone) {
|
||||
<button
|
||||
(click)="settings.setDarkTone(tone)"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.darkTone() === tone"
|
||||
[class.text-foreground]="settings.darkTone() === tone"
|
||||
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
||||
>
|
||||
{{ i18n.t('options.tone.' + tone) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.accent.label') }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
@for (tone of accents; track tone.value) {
|
||||
<button
|
||||
(click)="settings.setAccent(tone.value)"
|
||||
[title]="i18n.t('options.accentNames.' + tone.value)"
|
||||
class="size-3 rounded-full border border-foreground/15"
|
||||
[style.background-color]="tone.swatch"
|
||||
[class.ring-2]="settings.accent() === tone.value"
|
||||
[class.ring-foreground]="settings.accent() === tone.value"
|
||||
[class.ring-offset-1]="settings.accent() === tone.value"
|
||||
[class.ring-offset-card]="settings.accent() === tone.value"
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (mode of iconColorModes; track mode) {
|
||||
<button
|
||||
(click)="settings.setIconColors(mode)"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.iconColors() === mode"
|
||||
[class.text-foreground]="settings.iconColors() === mode"
|
||||
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
||||
>
|
||||
{{ i18n.t('options.fileIcons.' + mode) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
|
||||
steps. One lever moves the whole UI (root font-size; everything
|
||||
is rem), which is also why this popover itself grows with it. -->
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
|
||||
<div class="flex items-center rounded-md border border-border p-0.5">
|
||||
<button
|
||||
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
|
||||
[disabled]="settings.uiScale() <= 0.5"
|
||||
[title]="i18n.t('options.interface.smaller')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
(click)="settings.setUiScale(1)"
|
||||
[title]="i18n.t('options.interface.reset')"
|
||||
class="w-11 rounded px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
|
||||
>
|
||||
{{ scalePercent() }}
|
||||
</button>
|
||||
<button
|
||||
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
|
||||
[disabled]="settings.uiScale() >= 4"
|
||||
[title]="i18n.t('options.interface.larger')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.speedUnit.label') }}</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-[0.6875rem]"
|
||||
[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 class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.transfers.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (mode of transferModes; track mode) {
|
||||
<button
|
||||
(click)="settings.setTransferMode(mode)"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.transferMode() === mode"
|
||||
[class.text-foreground]="settings.transferMode() === mode"
|
||||
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
||||
>
|
||||
{{ i18n.t('options.transfers.' + mode) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (settings.transferMode() === 'multi') {
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (count of concurrencyChoices; track count) {
|
||||
<button
|
||||
(click)="settings.setTransferConcurrency(count)"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem] tabular-nums"
|
||||
[class.bg-accent]="settings.transferConcurrency() === count"
|
||||
[class.text-foreground]="settings.transferConcurrency() === count"
|
||||
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
||||
>
|
||||
{{ count }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.editor.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
<button
|
||||
(click)="settings.setEditor('builtin')"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.editor() === 'builtin'"
|
||||
[class.text-foreground]="settings.editor() === 'builtin'"
|
||||
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
||||
>
|
||||
{{ i18n.t('options.editor.builtin') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="settings.setEditor('custom')"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.editor() === 'custom'"
|
||||
[class.text-foreground]="settings.editor() === 'custom'"
|
||||
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
||||
>
|
||||
{{ i18n.t('options.editor.custom') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (settings.editor() === 'custom') {
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
||||
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
|
||||
{{ editorAppName() }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
(click)="pickEditorApp()"
|
||||
class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
|
||||
>
|
||||
{{ i18n.t('common.choose') }}
|
||||
</button>
|
||||
@if (settings.editorApp()) {
|
||||
<button
|
||||
(click)="settings.setEditorApp('')"
|
||||
[title]="i18n.t('options.resetDefault')"
|
||||
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.7rem" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.finishedTransfers.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
<button
|
||||
(click)="settings.setAutoClearCompleted(true)"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.autoClearCompleted()"
|
||||
[class.text-foreground]="settings.autoClearCompleted()"
|
||||
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
||||
>
|
||||
{{ i18n.t('options.finishedTransfers.autoClear') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="settings.setAutoClearCompleted(false)"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="!settings.autoClearCompleted()"
|
||||
[class.text-foreground]="!settings.autoClearCompleted()"
|
||||
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
||||
>
|
||||
{{ i18n.t('options.finishedTransfers.keep') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-refresh is always on for now. To expose a toggle, uncomment
|
||||
the breadcrumbs in types.ts + settings.service, then this row:
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Auto-refresh</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.autoRefresh()"
|
||||
[class.text-foreground]="settings.autoRefresh()"
|
||||
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
|
||||
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="!settings.autoRefresh()"
|
||||
[class.text-foreground]="!settings.autoRefresh()"
|
||||
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- about footer -->
|
||||
<button
|
||||
(click)="showAbout()"
|
||||
class="mt-3 w-full border-t border-border pt-2 text-center text-[0.6875rem] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{{ appName }} · {{ buildLabel }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class TabBar {
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly updater = inject(UpdaterService);
|
||||
private readonly contextMenu = inject(ContextMenuService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly localeOptions = SUPPORTED;
|
||||
readonly localeLabels = LOCALE_LABELS;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly buildLabel = BRANDING.buildLabel;
|
||||
/** What the window was BUILT with, not the current setting — the frameless
|
||||
* chrome only changes on reopen, so the hint compares against this. */
|
||||
readonly skins = SKINS;
|
||||
/**
|
||||
* The saved skin differs from the one this window actually booted, so the
|
||||
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
|
||||
* picked the running component tree; settings.skin() is merely persisted.
|
||||
*/
|
||||
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
|
||||
readonly themes = THEMES;
|
||||
readonly darkTones = DARK_TONES;
|
||||
readonly accents = ACCENTS;
|
||||
readonly iconColorModes = ICON_COLOR_MODES;
|
||||
readonly speedUnits = SPEED_UNITS;
|
||||
/** "100%" for the stepper readout; the value itself lives in settings. */
|
||||
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
|
||||
readonly transferModes = TRANSFER_MODES;
|
||||
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
||||
|
||||
// drag & drop reordering
|
||||
readonly draggedId = signal<number | null>(null);
|
||||
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
|
||||
|
||||
private readonly tabScroll = viewChild.required<ElementRef<HTMLMenuElement>>('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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middle-click closes the tab — the pointer path that replaced the per-tab ✕.
|
||||
* `auxclick` is the non-primary-button click event; guard on button 1 so the
|
||||
* right button (which also fires auxclick) never closes anything. Refuses the
|
||||
* last tab, matching closeWorkspace's own floor.
|
||||
*/
|
||||
onAuxClick(event: MouseEvent, id: number): void {
|
||||
if (event.button !== 1) return;
|
||||
event.preventDefault();
|
||||
if (this.panels.workspaces().length > 1) this.close(event, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click a tab. The menu targets THIS tab, not the active one — its
|
||||
* actions all carry the id — so right-clicking a background tab does not
|
||||
* yank focus to it first, the way Explorer and every browser behave.
|
||||
*/
|
||||
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
|
||||
event.preventDefault();
|
||||
this.contextMenu.open(
|
||||
event,
|
||||
this.contextMenu.tabItems({
|
||||
id: workspace.id,
|
||||
index,
|
||||
tabCount: this.panels.workspaces().length,
|
||||
isRemote: this.panels.workspaceIsRemote(workspace),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** "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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Component, inject, input, output, signal } from '@angular/core';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
|
||||
/**
|
||||
* The Aero title bar. Rendered by all three window kinds. The native frame is
|
||||
* gone on every platform (titleBarStyle 'hidden'), so this row IS the window's
|
||||
* chrome.
|
||||
*
|
||||
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
|
||||
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
|
||||
* Restore / Close each get their own glyph). Nothing here is styled with
|
||||
* Tailwind on purpose — the whole row comes from @layer skin.
|
||||
*
|
||||
* `.title-bar` lives on the HOST element, not on an inner div. 7.css couples
|
||||
* the glass treatment with a DIRECT-child selector — `.window.glass >
|
||||
* .title-bar` — and an inner div puts <app-title-bar> in between, so the rule
|
||||
* never matched: the title bar rendered as the opaque fallback with its own
|
||||
* border, visibly disconnected from the frame, instead of the translucent
|
||||
* streaked glass that fuses with it.
|
||||
*
|
||||
* Dragging rides on electrobun's preload drag-region classes. Two things
|
||||
* electrobun does NOT provide and this component implements:
|
||||
* - double-click to maximize (it only wires mousedown/mouseup)
|
||||
* - the maximize/restore glyph state, which has no event; bun derives it
|
||||
* from the native resize and pushes `windowState` (see trackMaximizeState).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
host: {
|
||||
class: 'title-bar electrobun-webkit-app-region-drag',
|
||||
// Published so the FRAME can react: a maximized Win7 window has square
|
||||
// corners. The window root reads this off its own child via :has() rather
|
||||
// than subscribing to windowState a second time — see styles.css.
|
||||
'[class.is-maximized]': 'maximized()',
|
||||
'(dblclick)': 'toggle()',
|
||||
},
|
||||
template: `
|
||||
<div class="title-bar-text">{{ title() }}</div>
|
||||
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
|
||||
@if (resizable()) {
|
||||
<button aria-label="Minimize" (click)="minimize()"></button>
|
||||
<button
|
||||
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
|
||||
(click)="toggle()"
|
||||
></button>
|
||||
}
|
||||
<button aria-label="Close" (click)="closeRequested.emit()"></button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class TitleBar {
|
||||
readonly title = input('');
|
||||
/** The manager window is a fixed-size modal — it gets Close only. */
|
||||
readonly resizable = input(true);
|
||||
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
protected readonly maximized = signal(false);
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
|
||||
constructor() {
|
||||
if (!this.rpc.available) return;
|
||||
// No maximize event exists, so seed from a one-shot read and then follow
|
||||
// the resize-derived push (which also covers Aero Snap and Win+Up).
|
||||
void this.rpc.request.windowIsMaximized({}).then((res) => {
|
||||
if (res.ok) this.maximized.set(res.maximized);
|
||||
});
|
||||
this.rpc.onWindowState((maximized) => this.maximized.set(maximized));
|
||||
}
|
||||
|
||||
protected minimize(): void {
|
||||
void this.rpc.request.windowMinimize({});
|
||||
}
|
||||
|
||||
protected toggle(): void {
|
||||
void this.rpc.request.windowToggleMaximize({}).then((res) => {
|
||||
if (res.ok) this.maximized.set(res.maximized);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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: `
|
||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
||||
@for (t of toast.toasts(); track t.id) {
|
||||
<div
|
||||
(mouseenter)="toast.pause(t.id)"
|
||||
(mouseleave)="toast.resume(t.id)"
|
||||
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="toastIcon(t.kind)"
|
||||
size="1rem"
|
||||
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
||||
<button
|
||||
(click)="toast.dismiss(t.id)"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
</button>
|
||||
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
||||
<div
|
||||
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
|
||||
[class.bg-destructive]="t.kind === 'error'"
|
||||
[class.bg-success]="t.kind === 'success'"
|
||||
[class.bg-primary]="t.kind === 'info'"
|
||||
[style.animation-duration.ms]="t.ttlMs"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
@if (transfers.expanded() && transfers.hasJobs()) {
|
||||
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
|
||||
<span>
|
||||
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
|
||||
</span>
|
||||
<button
|
||||
(click)="transfers.clearFinished()"
|
||||
[disabled]="!transfers.finishedJobs().length"
|
||||
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="has-scrollbar max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
||||
@for (job of transfers.jobs(); track job.id) {
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
||||
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||
@if (isActive(job)) {
|
||||
<!-- role+aria are 7.css's hook for the Win7 progress bar
|
||||
([role=progressbar] > div); under the modern skin they are
|
||||
inert and the Tailwind classes below still draw the bar. -->
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="jobPercent(job)"
|
||||
class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
||||
[style.width.%]="jobPercent(job)"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (isActive(job)) {
|
||||
<button
|
||||
(click)="transfers.cancel(job.id)"
|
||||
[title]="i18n.t('transfer.cancel')"
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="transfers.dismiss(job.id)"
|
||||
[title]="i18n.t('transfer.dismiss')"
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Frame arithmetic for the synthesized window resize. Deliberately a separate,
|
||||
* DEPENDENCY-FREE module: `scripts/resize-math-probe.ts` imports it directly to
|
||||
* assert all eight edges without booting Angular or opening a window, which is
|
||||
* the only automated coverage this feature can have (pointer drags cannot be
|
||||
* synthesized headlessly on this machine).
|
||||
*
|
||||
* Part of the resize stopgap — see the removal checklist in window-resize.ts.
|
||||
*/
|
||||
|
||||
export interface Frame {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Compass point of the grabbed edge or corner. */
|
||||
export type ResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
/**
|
||||
* Every edge the arithmetic supports — NOT what the UI mounts. The window
|
||||
* currently offers only the bottom-right grip (`window-resize.ts` explains why
|
||||
* the others were removed: an invisible handle over a caption button steals its
|
||||
* hover). This stays complete because the origin-moving cases are the subtle
|
||||
* ones and the probe is where they are proven; restoring a side is then one
|
||||
* entry in the component, against already-tested math.
|
||||
*/
|
||||
export const RESIZE_EDGES: readonly ResizeEdge[] = ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'];
|
||||
|
||||
/**
|
||||
* Floor for the synthesized resize. electrobun has no min-size window option,
|
||||
* so this is enforced here or not at all. Chosen to keep both panels usable
|
||||
* with their toolbars intact; the interface scale can still make the CONTENT
|
||||
* too big for it, which is a separate (pre-existing) problem.
|
||||
*/
|
||||
export const MIN_WIDTH = 640;
|
||||
export const MIN_HEIGHT = 420;
|
||||
|
||||
/**
|
||||
* The frame a drag should produce.
|
||||
*
|
||||
* `dx`/`dy` are SCREEN-space deltas from where the drag began (see the note on
|
||||
* screenX in window-resize.ts). North and west edges move the window's origin
|
||||
* as well as its size — that is the whole reason this returns a full frame
|
||||
* rather than a size, and why `setSize` alone would be wrong.
|
||||
*
|
||||
* The clamp holds the ANCHORED edge still: dragging the west edge east past the
|
||||
* minimum must park `x` where the window is exactly MIN_WIDTH wide, not let it
|
||||
* keep travelling while the width sits pinned — that would walk the window
|
||||
* across the screen while it appears frozen.
|
||||
*
|
||||
* Results are rounded: fractional frames make the native setter jitter by a
|
||||
* pixel between ticks.
|
||||
*/
|
||||
export function resizeFrame(
|
||||
edge: ResizeEdge,
|
||||
start: Frame,
|
||||
dx: number,
|
||||
dy: number,
|
||||
min: { width: number; height: number } = { width: MIN_WIDTH, height: MIN_HEIGHT },
|
||||
): Frame {
|
||||
let { x, y, width, height } = start;
|
||||
|
||||
if (edge.includes('e')) width = start.width + dx;
|
||||
if (edge.includes('w')) {
|
||||
width = start.width - dx;
|
||||
x = start.x + dx;
|
||||
}
|
||||
if (edge.includes('s')) height = start.height + dy;
|
||||
if (edge.includes('n')) {
|
||||
height = start.height - dy;
|
||||
y = start.y + dy;
|
||||
}
|
||||
|
||||
if (width < min.width) {
|
||||
// west drag: the east edge is the anchor, so x is derived from it
|
||||
if (edge.includes('w')) x = start.x + start.width - min.width;
|
||||
width = min.width;
|
||||
}
|
||||
if (height < min.height) {
|
||||
if (edge.includes('n')) y = start.y + start.height - min.height;
|
||||
height = min.height;
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
width: Math.round(width),
|
||||
height: Math.round(height),
|
||||
};
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { type Frame, type ResizeEdge, resizeFrame } from './resize-math';
|
||||
|
||||
/* ============================================================================
|
||||
* ⚠️ STOPGAP — DELETE THIS WHOLE FEATURE WHEN ELECTROBUN SHIPS WINDOW RESIZE
|
||||
* ============================================================================
|
||||
*
|
||||
* WHY IT EXISTS
|
||||
*
|
||||
* The win7 skin is frameless: `chromeOptions()` (src/bun/windows.ts) passes
|
||||
* `titleBarStyle: "hidden"` so 7.css can draw the Aero caption itself, and on
|
||||
* Linux the GTK wrapper honours that with `gtk_window_set_decorated(false)`.
|
||||
* Undecorating the window also removes the window manager's resize borders and
|
||||
* corner grips — so all three win7 windows became completely unresizable, on
|
||||
* every platform. (The modern skin keeps the native frame and is unaffected;
|
||||
* it must never mount this component.)
|
||||
*
|
||||
* Electrobun 1.18.1 offers nothing to re-enable. Verified against the SHIPPED
|
||||
* binary rather than assumed:
|
||||
*
|
||||
* - libNativeWrapper.so exports `startWindowMove` / `stopWindowMove` and no
|
||||
* resize counterpart whatsoever. Its only `resize*` exports are
|
||||
* `resizeWebview` / `resizeAutoSizingWebviewsInWindow`, internal webview
|
||||
* plumbing unrelated to the window frame.
|
||||
* - It imports `gtk_window_begin_move_drag` but NOT
|
||||
* `gtk_window_begin_resize_drag`, and not `gtk_window_set_resizable`.
|
||||
* - `dist/api/bun/preload/dragRegions.ts` implements moving only: there is no
|
||||
* `app-region: resize`, no per-edge variant.
|
||||
* - `BrowserWindow` has no `setResizable`, and its constructor options carry
|
||||
* no `resizable` / `minWidth` / `minHeight`.
|
||||
*
|
||||
* The window is still PROGRAMMATICALLY resizable — nothing ever calls
|
||||
* `gtk_window_set_resizable(false)`, and `setFrame`/`getFrame` work. So only
|
||||
* the input affordance is missing, and that is exactly what this synthesizes:
|
||||
* a bottom-right grip → pointer drag → `windowSetFrame` RPC →
|
||||
* `BrowserWindow.setFrame`. One corner, not eight handles — see GRIP_BOX for
|
||||
* why the others had to go.
|
||||
*
|
||||
* WHAT IT COSTS (i.e. why it should die as soon as it can)
|
||||
*
|
||||
* Every drag tick is an RPC hop plus a synchronous FFI call, where a real WM
|
||||
* grab is a compositor-side operation — so it is jerkier than a native resize
|
||||
* and always will be. Minimum size is enforced in JS because electrobun has no
|
||||
* min-size option. The grip occupies real estate INSIDE the window that a
|
||||
* native frame would have provided outside it — which is also why there is only
|
||||
* one of them: an invisible handle over a control steals that control's hover
|
||||
* (see GRIP_BOX). A real WM resize border has neither problem.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* REMOVAL CHECKLIST — when upstream ships a resize gesture, delete ALL of:
|
||||
*
|
||||
* 1. this directory (window-resize.ts + resize-math.ts)
|
||||
* 2. its three mount points: `<app-window-resize />` and the `WindowResize`
|
||||
* entry in `imports:` for skins/7like/{app, connection-manager/
|
||||
* connection-manager, editor-window/editor-window}
|
||||
* 3. the `.w7-resize` rule in skins/7like/7like.css (@layer skinfix)
|
||||
* 4. `windowGetFrame` / `windowSetFrame` in src/shared/rpc.ts
|
||||
* 5. their handlers in `windowControlHandlers` (src/bun/rpc-handlers.ts)
|
||||
* 6. scripts/check-electrobun-resize.ts and scripts/resize-math-probe.ts
|
||||
* 7. the check-script call in package.json's `postinstall`
|
||||
* 8. the resize paragraphs in docs/{gotchas,platform-notes,architecture}.md
|
||||
*
|
||||
* Then adopt whatever upstream provides — most likely a `no-drag`-style CSS
|
||||
* class or a `startWindowResize(edge)` preload call, which needs handles for
|
||||
* hit-testing but none of the frame arithmetic below.
|
||||
*
|
||||
* HOW WE FIND OUT: `scripts/check-electrobun-resize.ts` inspects the installed
|
||||
* electrobun for exactly those symbols and prints this checklist when they
|
||||
* appear. It runs from `postinstall`, so an electrobun bump surfaces it without
|
||||
* anyone remembering to look.
|
||||
* ============================================================================
|
||||
*
|
||||
* IMPLEMENTATION NOTES
|
||||
*
|
||||
* - `electrobun-webkit-app-region-no-drag` on every handle is LOAD-BEARING, not
|
||||
* decoration. `dragRegions.ts` starts a window MOVE from a `mousedown`
|
||||
* listener on `document` in the bubble phase; it never checks
|
||||
* `defaultPrevented`, so `preventDefault()` does not stop it, and a
|
||||
* `pointerdown` handler does not either (the compatibility `mousedown` still
|
||||
* fires afterwards, and stopping a POINTER event does nothing to a MOUSE
|
||||
* event). `isAppRegionDrag` tests for the no-drag class first and
|
||||
* short-circuits, so the class is the only reliable suppressor. Without it
|
||||
* every resize drag would also drag the window.
|
||||
* - Deltas come from `screenX/screenY`, never `clientX/clientY`. Resizing from
|
||||
* the north or west edge moves the window's origin, which moves the webview's
|
||||
* client origin under a stationary pointer — client coords would feed that
|
||||
* movement back into the next delta and the window would run away.
|
||||
* - The frame is read once per drag, asynchronously, but the pointer origin is
|
||||
* recorded synchronously in the same handler: a move that arrives before the
|
||||
* frame does is simply ignored, and no delta is measured from a stale origin.
|
||||
* - At most one setFrame is in flight, with the newest target coalesced behind
|
||||
* it. Firing one RPC per pointermove would queue faster than it drains and
|
||||
* the window would keep resizing after the mouse stopped.
|
||||
* - Handles are hidden when maximized by CSS alone (7like.css reads the title
|
||||
* bar's own `is-maximized` through `:has()`), so this component never
|
||||
* subscribes to `windowState`. That matters: RpcService's push registries
|
||||
* have no `off()`, so every subscription is permanent.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ONE handle: the bottom-right grip, exactly where Win7 put its resize gripper.
|
||||
*
|
||||
* A full eight-handle set was tried and reverted. The handles are invisible and
|
||||
* sit ON TOP of the frame (the overlay is above the title bar in paint order),
|
||||
* so the north strip ran the full width across the caption buttons and the
|
||||
* north-east square sat on the close button's own top-right corner. Crossing
|
||||
* one stole `:hover` from the button underneath, and since 7.css cross-fades
|
||||
* the caption glow, a stolen hover reads as the glow flickering or sticking —
|
||||
* "weird artifacts on the buttons", with no visual bug anywhere in the CSS.
|
||||
*
|
||||
* The bottom-right corner is the one place that overlaps nothing: it lands in
|
||||
* the 6px gutter that `.window-body { margin: 0 6px 6px }` leaves below and
|
||||
* right of the content. `resizeFrame` stays general (and the probe still covers
|
||||
* all eight edges) so restoring a side is one entry here — but any new handle
|
||||
* must be checked against what it covers first. South, east and west are the
|
||||
* innocent ones; north and both top corners are not.
|
||||
*
|
||||
* 1rem, not the 0.75rem the corners had: this is now the only way to resize the
|
||||
* window, so the target earns the extra pixels. Rem, so it scales with the
|
||||
* interface scale like everything else.
|
||||
*/
|
||||
const GRIP_EDGE: ResizeEdge = 'se';
|
||||
const GRIP_BOX = 'right-0 bottom-0 size-4 cursor-nwse-resize';
|
||||
|
||||
@Component({
|
||||
selector: 'app-window-resize',
|
||||
template: `
|
||||
<div
|
||||
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ gripBox }}"
|
||||
(pointerdown)="onPointerDown($event)"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerEnd()"
|
||||
(pointercancel)="onPointerEnd()"
|
||||
></div>
|
||||
`,
|
||||
// The overlay spans the frame but is transparent to the pointer; only the
|
||||
// handles inside it take hits. `w7-resize` is what the maximized rule hides.
|
||||
host: { class: 'w7-resize pointer-events-none absolute inset-0 z-10' },
|
||||
})
|
||||
export class WindowResize {
|
||||
protected readonly gripBox = GRIP_BOX;
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
|
||||
/** Null except during a drag. Set only once the frame read resolves. */
|
||||
private drag: { screenX: number; screenY: number; frame: Frame } | null = null;
|
||||
/** Newest target frame not yet sent, if a send is already in flight. */
|
||||
private pending: Frame | null = null;
|
||||
private sending = false;
|
||||
|
||||
onPointerDown(event: PointerEvent): void {
|
||||
if (!this.rpc.available || event.button !== 0) return;
|
||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||
// captured synchronously — the frame read below is a round trip
|
||||
const screenX = event.screenX;
|
||||
const screenY = event.screenY;
|
||||
void this.rpc.request.windowGetFrame({}).then((res) => {
|
||||
if (!res.ok) return;
|
||||
this.drag = {
|
||||
screenX,
|
||||
screenY,
|
||||
frame: { x: res.x, y: res.y, width: res.width, height: res.height },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onPointerMove(event: PointerEvent): void {
|
||||
const drag = this.drag;
|
||||
if (!drag) return; // moved before the frame arrived, or not dragging
|
||||
this.pending = resizeFrame(
|
||||
GRIP_EDGE,
|
||||
drag.frame,
|
||||
event.screenX - drag.screenX,
|
||||
event.screenY - drag.screenY,
|
||||
);
|
||||
void this.flush();
|
||||
}
|
||||
|
||||
onPointerEnd(): void {
|
||||
this.drag = null; // capture releases itself on pointerup/cancel
|
||||
}
|
||||
|
||||
/** Drain `pending`, one request at a time. Re-entrant calls return
|
||||
* immediately; the running loop picks up whatever they left behind. */
|
||||
private async flush(): Promise<void> {
|
||||
if (this.sending) return;
|
||||
this.sending = true;
|
||||
try {
|
||||
while (this.pending) {
|
||||
const frame = this.pending;
|
||||
this.pending = null;
|
||||
await this.rpc.request.windowSetFrame(frame);
|
||||
}
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,9 @@ 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.
|
||||
* The three window roots, keyed by APP_MODE — main.ts picks one. There is no
|
||||
* title-bar component: the native window frame is kept (hiddenInset on macOS,
|
||||
* which is what IS_MAC_CHROME accounts for).
|
||||
*/
|
||||
export const SHAD_ROOTS = {
|
||||
main: App,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/* ==========================================================================
|
||||
shadlike skin — the modern, shadcn-style look
|
||||
==========================================================================
|
||||
|
||||
Intentionally near-empty, and that is not an oversight.
|
||||
|
||||
This skin IS the shared foundation: the OKLCH token system, `@theme inline`,
|
||||
the panel-chrome metrics and the accent axis all live in ui/src/styles.css
|
||||
because both skins consume them, and everything else the modern look needs is
|
||||
a Tailwind utility written inline in its templates. There is no third-party
|
||||
framework to bridge and no cascade inversion to undo, so there is simply
|
||||
nothing left for a skin-level stylesheet to say.
|
||||
|
||||
Compare app/skins/7like/7like.css, which is 630 lines: all of it exists to
|
||||
import 7.css above `utilities`, re-point the app's semantic tokens at Win7
|
||||
values, and hand individual properties back where 7.css's dialog metrics are
|
||||
wrong for this app.
|
||||
|
||||
This file exists so the folder is self-contained and symmetrical with the
|
||||
other skin — modern-only rules that should not leak into the win7 cascade
|
||||
have a home here. Rules that BOTH skins need belong in ui/src/styles.css
|
||||
instead; anything added here is dead weight under .win7.
|
||||
|
||||
Scoping note: 7like scopes itself with the `.win7` class that 7.scoped.css
|
||||
requires. This skin is the default (no marker class), so any rule added here
|
||||
must NOT be written so broadly that it also matches under .win7 — prefer
|
||||
scoping to the component, not to bare elements. */
|
||||
@@ -6,12 +6,11 @@ import type { AccentTone } from '@shared/types';
|
||||
import { ContextMenuService } from '@core/context-menu.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||
import { APP_SKIN, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||
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 SKINS = ['modern', 'win7'] as const;
|
||||
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
|
||||
@@ -182,36 +181,6 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
|
||||
(ui/src/app/skins/) and the native frame, and neither can change
|
||||
on a live window — the tree is bootstrapped from the preload-baked
|
||||
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
|
||||
constructor options. Clicking only saves; the running UI is left
|
||||
alone. Highlight the SAVED value so the click registers, and show
|
||||
the pending hint whenever it differs from the booted skin. -->
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (s of skins; track s) {
|
||||
<button
|
||||
(click)="settings.setSkin(s)"
|
||||
[title]="i18n.t('options.skin.restartHint')"
|
||||
class="rounded px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.skin() === s"
|
||||
[class.text-foreground]="settings.skin() === s"
|
||||
[class.text-muted-foreground]="settings.skin() !== s"
|
||||
>
|
||||
{{ i18n.t('options.skin.' + s) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (skinPending()) {
|
||||
<p class="mt-1 text-right text-[0.625rem] text-muted-foreground">
|
||||
{{ i18n.t('options.skin.restartHint') }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@@ -484,13 +453,6 @@ export class TabBar {
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly buildLabel = BRANDING.buildLabel;
|
||||
readonly skins = SKINS;
|
||||
/**
|
||||
* The saved skin differs from the one this window actually booted, so the
|
||||
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
|
||||
* picked the running component tree; settings.skin() is merely persisted.
|
||||
*/
|
||||
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
|
||||
readonly themes = THEMES;
|
||||
readonly darkTones = DARK_TONES;
|
||||
readonly accents = ACCENTS;
|
||||
|
||||
+13
-6
@@ -6,13 +6,20 @@
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<!-- Pre-stylesheet paint: the window background must be right BEFORE
|
||||
styles.css parses or new windows flash white/inverted. Only the
|
||||
html background decision lives here — values must stay in sync with
|
||||
the corresponding --background tokens in styles.css. The data-*
|
||||
attributes are baked in by the bun preload (buildPreload in
|
||||
src/bun/windows.ts) so forced themes are correct at first paint. -->
|
||||
<style>
|
||||
/* The native window is transparent (chromeOptions), so the page itself
|
||||
must not paint a background — the Aero frame in styles.css is the only
|
||||
opaque surface, and its radius/drop shadow need to fall outside it.
|
||||
Windows are created hidden and revealed on dom-ready, so there is no
|
||||
unthemed flash to guard against here any more. */
|
||||
html.win7, html.win7 body { background: transparent; }
|
||||
html { background: oklch(1 0 0); }
|
||||
html[data-theme='dark'] { background: oklch(0.2178 0.002 107); }
|
||||
html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0 0 0); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme='light']) { background: oklch(0.2178 0.002 107); }
|
||||
html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+4
-14
@@ -1,19 +1,9 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from '@core/app.config';
|
||||
import { APP_MODE, APP_SKIN } from '@core/platform';
|
||||
import { SEVEN_ROOTS } from './app/skins/7like';
|
||||
import { APP_MODE } from '@core/platform';
|
||||
import { SHAD_ROOTS } from './app/skins/shadlike';
|
||||
|
||||
// One bundle, two skins x three window kinds. Both globals are baked into the
|
||||
// preload (src/bun/windows.ts buildPreload) because both are needed before the
|
||||
// first paint: settings arrive over an async RPC, far too late to choose a
|
||||
// component tree without flashing the wrong UI.
|
||||
//
|
||||
// Every skin's barrel exports the same { main, manager, editor } shape, so this
|
||||
// file never learns anything about either tree's internals. Both are imported
|
||||
// statically and ship in one bundle — the inactive skin costs bundle size but
|
||||
// no runtime work, which is the right trade for a local desktop app and keeps
|
||||
// bootstrap synchronous.
|
||||
const tree = APP_SKIN === 'win7' ? SEVEN_ROOTS : SHAD_ROOTS;
|
||||
const root = APP_MODE === 'editor' ? tree.editor : APP_MODE === 'manager' ? tree.manager : tree.main;
|
||||
// One bundle, three window kinds: the preload global picks the root component.
|
||||
const root =
|
||||
APP_MODE === 'editor' ? SHAD_ROOTS.editor : APP_MODE === 'manager' ? SHAD_ROOTS.manager : SHAD_ROOTS.main;
|
||||
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
|
||||
|
||||
+2
-20
@@ -1,21 +1,4 @@
|
||||
/* Layer order — declared BEFORE the imports so it wins (a @layer statement is
|
||||
one of the only two rule types allowed above @import). `skin` sorts after
|
||||
`utilities`, so every 7.css rule outranks every Tailwind utility on the
|
||||
elements it targets; that inversion is what lets the win7 skin restyle
|
||||
~1900 inline utility classes without touching the templates. `skinfix` is
|
||||
above both, and uses `revert-layer` to hand individual properties back to
|
||||
`utilities` where 7.css's Win7-dialog metrics are wrong for this app.
|
||||
Tailwind re-declares the first four names, a no-op once ordered here.
|
||||
See the "Windows 7 skin" section at the bottom of this file. */
|
||||
@layer theme, base, components, utilities, skin, skinfix;
|
||||
@import "tailwindcss";
|
||||
/* The two skin stylesheets. Both ship in one bundle and are scoped by the
|
||||
`.win7` class on <html>, exactly as when they lived in this file — only one
|
||||
skin's rules can match at a time. They sit here because CSS requires every
|
||||
@import to precede all other rules; layer assignment is fixed by the @layer
|
||||
statement above, not by this order. */
|
||||
@import "./app/skins/shadlike/shadlike.css";
|
||||
@import "./app/skins/7like/7like.css";
|
||||
|
||||
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a
|
||||
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
|
||||
@@ -192,9 +175,8 @@
|
||||
/* Panel-chrome metrics: file list, path bar, action buttons, source picker.
|
||||
ALL REM, deliberately — the interface scale (settings.uiScale) works by
|
||||
setting the root font-size, so every rem value in the app scales from that
|
||||
one lever. A px value here would freeze while everything around it grew,
|
||||
which is exactly what happened to the win7 boxes before stage 1c. There is
|
||||
only one tier now: the old data-text compact/large tiers are gone, replaced
|
||||
one lever. A px value here would freeze while everything around it grew.
|
||||
There is only one tier now: the old data-text compact/large tiers are gone, replaced
|
||||
by the continuous scale. Values are the old default tier verbatim
|
||||
(13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical
|
||||
to the pre-scale app. */
|
||||
|
||||
Reference in New Issue
Block a user