Move settings into a standalone Settings window
The tab-bar popover had outgrown itself. Settings now open in their own native window (a fourth window kind, built like the connection manager but not modal): a sidebar with Appearance, Transfers, Editor, File list and About, holding every popover row plus the visible-columns choice. Each control is an ordinary settings save, so the other windows follow live while it stays open. The gear button in the tab bar, Ctrl+, and the macOS app menu's "Settings..." (Cmd+,) open it. On macOS Cmd+W arrives as the main window's close-tab menu action, so bun closes the focused Settings window first instead of a main-window tab. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
|
|||||||
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
|
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
|
||||||
to bring the app to the front.
|
to bring the app to the front.
|
||||||
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
|
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
|
||||||
and remote files directly; or pick an external app (Options → Editor →
|
and remote files directly; or pick an external app (Settings → Editor →
|
||||||
Custom → Choose…). External edits of remote files download to a temp folder,
|
Custom → Choose…). External edits of remote files download to a temp folder,
|
||||||
are watched, and every save auto-uploads (visible in the transfer list) until
|
are watched, and every save auto-uploads (visible in the transfer list) until
|
||||||
the session disconnects. Leftover temp files from a crash are offered for
|
the session disconnects. Leftover temp files from a crash are offered for
|
||||||
@@ -181,7 +181,7 @@ Saved connections live in a plain JSON file in the OS app-data directory
|
|||||||
`Space` select · `Enter` open folder · `Backspace` parent ·
|
`Space` select · `Enter` open folder · `Backspace` parent ·
|
||||||
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
|
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
|
||||||
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
||||||
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab ·
|
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab · `Cmd/Ctrl+,` settings ·
|
||||||
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
|
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
|
||||||
|
|
||||||
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
||||||
|
|||||||
+29
-13
@@ -30,8 +30,8 @@ Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
|
|||||||
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
|
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
|
||||||
transport stolen by the most recent window. `createAppRPC` (main),
|
transport stolen by the most recent window. `createAppRPC` (main),
|
||||||
`createEditorRPC` (per editor window, hooks close over that window's record),
|
`createEditorRPC` (per editor window, hooks close over that window's record),
|
||||||
`createManagerRPC` (the single manager window). `rpc.send.*` on an instance
|
`createManagerRPC` (the single manager window), `createSettingsRPC` (the single
|
||||||
targets exactly its own webview.
|
Settings window). `rpc.send.*` on an instance targets exactly its own webview.
|
||||||
|
|
||||||
### The `events` late-binding pattern
|
### The `events` late-binding pattern
|
||||||
|
|
||||||
@@ -39,15 +39,15 @@ targets exactly its own webview.
|
|||||||
exist until after construction). `AppDeps.events` is a mutable object of
|
exist until after construction). `AppDeps.events` is a mutable object of
|
||||||
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
||||||
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
|
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
|
||||||
`openConnectionManager`, `showAbout`.
|
`openConnectionManager`, `openSettings`, `showAbout`.
|
||||||
|
|
||||||
## Window kinds
|
## Window kinds
|
||||||
|
|
||||||
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||||||
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any
|
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager|settings`), `__APP_CTX`
|
||||||
page script, plus the persisted theme attributes and interface scale so the
|
before any page script, plus the persisted theme attributes and interface
|
||||||
first paint is already right. `ui/src/main.ts` picks the root component from
|
scale so the first paint is already right. `ui/src/main.ts` picks the root component from
|
||||||
`APP_MODE`; all three roots use selector `app-root` (index.html hosts only that
|
`APP_MODE`; all four roots use selector `app-root` (index.html hosts only that
|
||||||
tag). `chromeOptions()` is the ONLY place the native frame is decided, spread by
|
tag). `chromeOptions()` is the ONLY place the native frame is decided, spread by
|
||||||
the main window and `openSecondaryWindow`: macOS `hiddenInset` +
|
the main window and `openSecondaryWindow`: macOS `hiddenInset` +
|
||||||
`trafficLightOffset {x:12,y:13}` (the UI renders an `h-10 pl-24` drag-region
|
`trafficLightOffset {x:12,y:13}` (the UI renders an `h-10 pl-24` drag-region
|
||||||
@@ -103,6 +103,20 @@ main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
|
|||||||
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
|
||||||
would float over editor windows).
|
would float over editor windows).
|
||||||
|
|
||||||
|
### Settings window (`src/bun/settings-window.ts` + `ui/src/app/skins/shadlike/settings-window/`)
|
||||||
|
|
||||||
|
Single instance, centered over main, **not modal**: every control is a plain
|
||||||
|
`SettingsService` setter, so each edit is an ordinary save that bun
|
||||||
|
rebroadcasts — the other windows follow live while it stays open. Opened by
|
||||||
|
the tab-bar gear and Ctrl+, (`openSettings` from main) and, on macOS, the app
|
||||||
|
menu's "Settings…" (⌘,, which the menu intercepts first). Its RPC serves only
|
||||||
|
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`. ⌘W reaches bun as
|
||||||
|
the main window's `close-tab` menu action, so bun checks the Settings window's
|
||||||
|
focus FIRST and closes it (see gotchas). Sidebar sections: Appearance,
|
||||||
|
Transfers, Editor, File list (visible columns — the column-header right-click
|
||||||
|
menu toggles the same setting), About (opens the native About box). The
|
||||||
|
native title is `options.title`, retitled on a locale switch.
|
||||||
|
|
||||||
## Bun modules
|
## Bun modules
|
||||||
|
|
||||||
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
||||||
@@ -152,8 +166,9 @@ would float over editor windows).
|
|||||||
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
||||||
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
||||||
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||||||
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
|
Connect submenu mirrors the store; "Settings…" carries ⌘,). ⌘W is intercepted
|
||||||
routes `close-tab` to the focused editor window first. Labels go through
|
by the menu, so bun routes `close-tab` to a focused Settings or editor window
|
||||||
|
first. Labels go through
|
||||||
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
||||||
- `i18n.ts` — backend translator (native menus + thrown error messages, which
|
- `i18n.ts` — backend translator (native menus + thrown error messages, which
|
||||||
no Angular service can reach). `setLocale(setting)` caches the resolved
|
no Angular service can reach). `setLocale(setting)` caches the resolved
|
||||||
@@ -175,7 +190,7 @@ would float over editor windows).
|
|||||||
ui/src/app/
|
ui/src/app/
|
||||||
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
||||||
metrics.ts and every *.service.ts — the non-visual layer
|
metrics.ts and every *.service.ts — the non-visual layer
|
||||||
skins/shadlike/ the component tree: three window roots (SHAD_ROOTS barrel,
|
skins/shadlike/ the component tree: four window roots (SHAD_ROOTS barrel,
|
||||||
picked by main.ts), dialogs, context menu
|
picked by main.ts), dialogs, context menu
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -220,8 +235,9 @@ component; tokens → `ui/src/styles.css`.
|
|||||||
(`applyTheme/DarkTone/Accent` set `data-*` attributes on `<html>`;
|
(`applyTheme/DarkTone/Accent` set `data-*` attributes on `<html>`;
|
||||||
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the
|
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the
|
||||||
`settingsChanged` broadcast, so every window follows a save made in any
|
`settingsChanged` broadcast, so every window follows a save made in any
|
||||||
other. All three window RPCs serve `saveSettings` (editor and manager windows
|
other. Every window RPC serves `saveSettings` (editor and manager windows
|
||||||
save too — the scale shortcuts); bun's `settingsSaved()` in index.ts runs the
|
save too — the scale shortcuts — and the Settings window is nothing but
|
||||||
|
saves); bun's `settingsSaved()` in index.ts runs the
|
||||||
side effects once and rebroadcasts to every window **except the sender**, so
|
side effects once and rebroadcasts to every window **except the sender**, so
|
||||||
there is no echo to fight a held Cmd+. `applyAll` never persists. New setting
|
there is no echo to fight a held Cmd+. `applyAll` never persists. New setting
|
||||||
= five touches here + types.ts (backend merges automatically).
|
= five touches here + types.ts (backend merges automatically).
|
||||||
@@ -316,7 +332,7 @@ pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale
|
|||||||
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
|
||||||
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
|
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
|
||||||
whole UI scales, chrome included.
|
whole UI scales, chrome included.
|
||||||
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a
|
Driven by the Settings window's stepper, Ctrl/Cmd +/−/0, and preload-baked for a
|
||||||
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
|
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
|
||||||
migrates old `textSize` values. TS that measures rem-sized CSS converts through
|
migrates old `textSize` values. TS that measures rem-sized CSS converts through
|
||||||
`@core/metrics` `remPx()` — never an assumed 16.
|
`@core/metrics` `remPx()` — never an assumed 16.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ electrobun}`). It is regenerated by `postinstall` and at the start of every
|
|||||||
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
|
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
|
||||||
run once (postinstall creates the file). `BRANDING.version` stays
|
run once (postinstall creates the file). `BRANDING.version` stays
|
||||||
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
||||||
("Build n") is what humans see (About box, options popover).
|
("Build n") is what humans see (About box, Settings → About).
|
||||||
|
|
||||||
## Dev loop
|
## Dev loop
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ touching the adjacent code.
|
|||||||
app-level `before-quit` supports `event.response = {allow:false}`). All
|
app-level `before-quit` supports `event.response = {allow:false}`). All
|
||||||
unsaved-state guards must either intercept app-controlled close paths
|
unsaved-state guards must either intercept app-controlled close paths
|
||||||
(⌘W, buttons) or survive the close (editor draft mirroring).
|
(⌘W, buttons) or survive the close (editor draft mirroring).
|
||||||
|
- **⌘W in any secondary window arrives as the MAIN window's `close-tab`
|
||||||
|
menu action** (macOS: the app menu intercepts it before the webview sees
|
||||||
|
the key). bun must check the focused secondary window first — the Settings
|
||||||
|
window, then editors — and close THAT; a new window kind that skips this
|
||||||
|
check makes ⌘W close a main-window tab behind it.
|
||||||
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
||||||
transport; a shared instance silently sends to the wrong webview.
|
transport; a shared instance silently sends to the wrong webview.
|
||||||
- **RPC before socket open is silently lost.** Everything gates on the
|
- **RPC before socket open is silently lost.** Everything gates on the
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
|
|||||||
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||||
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
||||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
|
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
|
||||||
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none (unsupported upstream) |
|
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
|
||||||
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
||||||
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||||
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||||
@@ -172,7 +172,7 @@ Code paths written blind (no Windows box was available); verify in order:
|
|||||||
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
|
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
|
||||||
8. connections.json ACL: `icacls connections.json` shows only the current
|
8. connections.json ACL: `icacls connections.json` shows only the current
|
||||||
user after saving a connection.
|
user after saving a connection.
|
||||||
9. Editor/manager windows open, modal bounce works, About shows from the
|
9. Editor/manager/Settings windows open, modal bounce works, Ctrl+, opens
|
||||||
options popover.
|
Settings and About shows from its About section.
|
||||||
10. Known-issue confirmation: HiDPI blur on a scaled display (expected until
|
10. Known-issue confirmation: HiDPI blur on a scaled display (expected until
|
||||||
upstream fixes it).
|
upstream fixes it).
|
||||||
|
|||||||
+34
-8
@@ -10,6 +10,7 @@ import { ManagerWindow } from "./manager-window";
|
|||||||
import { setLocale as setBackendLocale } from "./i18n";
|
import { setLocale as setBackendLocale } from "./i18n";
|
||||||
import { applyApplicationMenu } from "./menu";
|
import { applyApplicationMenu } from "./menu";
|
||||||
import { createAppRPC } from "./rpc-handlers";
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
|
import { SettingsWindow } from "./settings-window";
|
||||||
import { ConnectionStore } from "./store/connections";
|
import { ConnectionStore } from "./store/connections";
|
||||||
import { migrateLegacyDataDir } from "./store/paths";
|
import { migrateLegacyDataDir } from "./store/paths";
|
||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
@@ -52,6 +53,7 @@ const events = {
|
|||||||
openEditor: (_ctx: EditorOpenContext) => {},
|
openEditor: (_ctx: EditorOpenContext) => {},
|
||||||
settingsChanged: (_settings: AppSettings) => {},
|
settingsChanged: (_settings: AppSettings) => {},
|
||||||
openConnectionManager: () => {},
|
openConnectionManager: () => {},
|
||||||
|
openSettings: () => {},
|
||||||
showAbout: () => {},
|
showAbout: () => {},
|
||||||
};
|
};
|
||||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
|
||||||
@@ -108,29 +110,43 @@ const manager = new ManagerWindow({
|
|||||||
rebuildMenu: () => void applyApplicationMenu(store),
|
rebuildMenu: () => void applyApplicationMenu(store),
|
||||||
onSettingsSaved: (s) => settingsSaved(s, "manager"),
|
onSettingsSaved: (s) => settingsSaved(s, "manager"),
|
||||||
});
|
});
|
||||||
|
const settingsWin = new SettingsWindow({
|
||||||
|
settings,
|
||||||
|
url,
|
||||||
|
currentSettings: () => currentSettings,
|
||||||
|
mainFrame: () => win.getFrame(),
|
||||||
|
onSettingsSaved: (s) => settingsSaved(s, "settings"),
|
||||||
|
showAbout: () => showAbout(),
|
||||||
|
});
|
||||||
|
|
||||||
events.openEditor = (ctx) => editors.open(ctx);
|
events.openEditor = (ctx) => editors.open(ctx);
|
||||||
/**
|
/**
|
||||||
* Every settings save lands here, whichever window made it (editor and manager
|
* Every settings save lands here, whichever window made it (editor and manager
|
||||||
* windows save too — the scale shortcuts): side effects run once, then every
|
* windows save too — the scale shortcuts — and the Settings window is nothing
|
||||||
* OTHER window follows. The sender is skipped — it already shows the new
|
* but saves): side effects run once, then every OTHER window follows. The
|
||||||
* values, and an echo arriving behind a newer local change (a held Cmd+) would
|
* sender is skipped — it already shows the new values, and an echo arriving
|
||||||
* drag it backwards.
|
* behind a newer local change (a held Cmd+) would drag it backwards.
|
||||||
*/
|
*/
|
||||||
function settingsSaved(s: AppSettings, sender: "main" | "manager" | { editor: string }): void {
|
function settingsSaved(
|
||||||
|
s: AppSettings,
|
||||||
|
sender: "main" | "manager" | "settings" | { editor: string },
|
||||||
|
): void {
|
||||||
engine.setConcurrency(effectiveConcurrency(s));
|
engine.setConcurrency(effectiveConcurrency(s));
|
||||||
const localeChanged = s.locale !== currentSettings.locale;
|
const localeChanged = s.locale !== currentSettings.locale;
|
||||||
currentSettings = s; // future windows' preloads bake the fresh theme
|
currentSettings = s; // future windows' preloads bake the fresh theme
|
||||||
if (localeChanged) {
|
if (localeChanged) {
|
||||||
setBackendLocale(s.locale);
|
setBackendLocale(s.locale);
|
||||||
void applyApplicationMenu(store); // relabel the native menu in the new language
|
void applyApplicationMenu(store); // relabel the native menu in the new language
|
||||||
|
settingsWin.retitle();
|
||||||
}
|
}
|
||||||
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
|
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
|
||||||
if (sender !== "manager") manager.broadcastSettings(s);
|
if (sender !== "manager") manager.broadcastSettings(s);
|
||||||
|
if (sender !== "settings") settingsWin.broadcastSettings(s);
|
||||||
editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined);
|
editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined);
|
||||||
}
|
}
|
||||||
events.settingsChanged = (s) => settingsSaved(s, "main");
|
events.settingsChanged = (s) => settingsSaved(s, "main");
|
||||||
events.openConnectionManager = () => manager.open();
|
events.openConnectionManager = () => manager.open();
|
||||||
|
events.openSettings = () => settingsWin.open();
|
||||||
|
|
||||||
function showAbout(): void {
|
function showAbout(): void {
|
||||||
const { build } = BRANDING;
|
const { build } = BRANDING;
|
||||||
@@ -149,6 +165,7 @@ events.showAbout = showAbout;
|
|||||||
// and the modal manager has nothing to guard
|
// and the modal manager has nothing to guard
|
||||||
win.on("close", () => {
|
win.on("close", () => {
|
||||||
manager.close();
|
manager.close();
|
||||||
|
settingsWin.close();
|
||||||
editors.closeAllForAppExit();
|
editors.closeAllForAppExit();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +190,7 @@ engine.onUpdate = (snapshot) => {
|
|||||||
if (
|
if (
|
||||||
!windowFocused &&
|
!windowFocused &&
|
||||||
!editors.anyFocused() &&
|
!editors.anyFocused() &&
|
||||||
|
!settingsWin.isFocused() &&
|
||||||
(snapshot.state === "completed" || snapshot.state === "failed")
|
(snapshot.state === "completed" || snapshot.state === "failed")
|
||||||
) {
|
) {
|
||||||
Utils.showNotification({
|
Utils.showNotification({
|
||||||
@@ -230,10 +248,14 @@ void applyApplicationMenu(store);
|
|||||||
ApplicationMenu.on("application-menu-clicked", (event) => {
|
ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||||
const action = (event as { data?: { action?: string } }).data?.action;
|
const action = (event as { data?: { action?: string } }).data?.action;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
// ⌘W while an editor window is focused closes that editor (with its dirty
|
// ⌘W while the Settings window or an editor window is focused closes THAT
|
||||||
// prompt), not a main-window tab. Other menu actions deliberately still
|
// window (an editor with its dirty prompt), not a main-window tab. Other
|
||||||
// target the main window.
|
// menu actions deliberately still target the main window.
|
||||||
if (action === "close-tab") {
|
if (action === "close-tab") {
|
||||||
|
if (settingsWin.isFocused()) {
|
||||||
|
settingsWin.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const focusedEditor = editors.focusedKey();
|
const focusedEditor = editors.focusedKey();
|
||||||
if (focusedEditor) {
|
if (focusedEditor) {
|
||||||
editors.requestClose(focusedEditor);
|
editors.requestClose(focusedEditor);
|
||||||
@@ -244,6 +266,10 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
|
|||||||
manager.open();
|
manager.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "settings") {
|
||||||
|
settingsWin.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === "about") {
|
if (action === "about") {
|
||||||
showAbout();
|
showAbout();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
|||||||
submenu: [
|
submenu: [
|
||||||
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
|
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
{ label: t("menu.settings"), action: "settings", accelerator: "," },
|
||||||
|
{ type: "separator" },
|
||||||
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
|
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{ role: "hide" },
|
||||||
|
|||||||
+56
-20
@@ -31,6 +31,7 @@ export interface AppDeps {
|
|||||||
openEditor: (ctx: EditorOpenContext) => void;
|
openEditor: (ctx: EditorOpenContext) => void;
|
||||||
settingsChanged: (settings: AppSettings) => void;
|
settingsChanged: (settings: AppSettings) => void;
|
||||||
openConnectionManager: () => void;
|
openConnectionManager: () => void;
|
||||||
|
openSettings: () => void;
|
||||||
showAbout: () => void;
|
showAbout: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -130,6 +131,28 @@ const pickKeyFileHandler = () =>
|
|||||||
return { path };
|
return { path };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** External-editor picker, shared by the main and settings window rpcs. */
|
||||||
|
const pickEditorAppHandler = () =>
|
||||||
|
wrap(async () => {
|
||||||
|
// PORTABILITY: apps live in different places per OS
|
||||||
|
const { homedir } = await import("node:os");
|
||||||
|
const [startingFolder, allowedFileTypes] =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? ["/Applications", "app"]
|
||||||
|
: process.platform === "win32"
|
||||||
|
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
|
||||||
|
: [homedir(), "*"];
|
||||||
|
const picked = await Utils.openFileDialog({
|
||||||
|
startingFolder,
|
||||||
|
allowedFileTypes,
|
||||||
|
canChooseFiles: true,
|
||||||
|
canChooseDirectory: false,
|
||||||
|
allowsMultipleSelection: false,
|
||||||
|
});
|
||||||
|
const path = picked.find((p) => p.trim().length > 0) ?? null;
|
||||||
|
return { path };
|
||||||
|
});
|
||||||
|
|
||||||
export function createAppRPC({
|
export function createAppRPC({
|
||||||
registry,
|
registry,
|
||||||
engine,
|
engine,
|
||||||
@@ -257,26 +280,7 @@ export function createAppRPC({
|
|||||||
editExternal: ({ sessionId, path }) =>
|
editExternal: ({ sessionId, path }) =>
|
||||||
wrap(() => edits.editExternal(sessionId, path)),
|
wrap(() => edits.editExternal(sessionId, path)),
|
||||||
|
|
||||||
pickEditorApp: () =>
|
pickEditorApp: pickEditorAppHandler,
|
||||||
wrap(async () => {
|
|
||||||
// PORTABILITY: apps live in different places per OS
|
|
||||||
const { homedir } = await import("node:os");
|
|
||||||
const [startingFolder, allowedFileTypes] =
|
|
||||||
process.platform === "darwin"
|
|
||||||
? ["/Applications", "app"]
|
|
||||||
: process.platform === "win32"
|
|
||||||
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
|
|
||||||
: [homedir(), "*"];
|
|
||||||
const picked = await Utils.openFileDialog({
|
|
||||||
startingFolder,
|
|
||||||
allowedFileTypes,
|
|
||||||
canChooseFiles: true,
|
|
||||||
canChooseDirectory: false,
|
|
||||||
allowsMultipleSelection: false,
|
|
||||||
});
|
|
||||||
const path = picked.find((p) => p.trim().length > 0) ?? null;
|
|
||||||
return { path };
|
|
||||||
}),
|
|
||||||
|
|
||||||
pickKeyFile: pickKeyFileHandler,
|
pickKeyFile: pickKeyFileHandler,
|
||||||
|
|
||||||
@@ -318,6 +322,13 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ---- settings window ----
|
||||||
|
openSettings: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
events.openSettings();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
showAbout: () =>
|
showAbout: () =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
events.showAbout();
|
events.showAbout();
|
||||||
@@ -449,3 +460,28 @@ export function createManagerRPC(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SettingsRpcHooks {
|
||||||
|
/** Every edit here is a settings save — rebroadcast it to the other windows. */
|
||||||
|
onSettingsSaved: (next: AppSettings) => void;
|
||||||
|
showAbout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RPC for the single Settings window (per-window instance). */
|
||||||
|
export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: SettingsRpcHooks) {
|
||||||
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
|
handlers: {
|
||||||
|
requests: {
|
||||||
|
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||||
|
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
|
||||||
|
pickEditorApp: pickEditorAppHandler,
|
||||||
|
showAbout: () =>
|
||||||
|
wrap(async () => {
|
||||||
|
hooks.showAbout();
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { BrowserWindow } from "electrobun/bun";
|
||||||
|
import type { AppSettings } from "../shared/types";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { createSettingsRPC } from "./rpc-handlers";
|
||||||
|
import type { SettingsStore } from "./store/settings";
|
||||||
|
import { openSecondaryWindow } from "./windows";
|
||||||
|
|
||||||
|
const SETTINGS_WIDTH = 680;
|
||||||
|
const SETTINGS_HEIGHT = 480;
|
||||||
|
|
||||||
|
export interface SettingsWindowDeps {
|
||||||
|
settings: SettingsStore;
|
||||||
|
/** The once-resolved dev/prod view url. */
|
||||||
|
url: string;
|
||||||
|
/** Live settings snapshot — baked into the window's preload (theme flash). */
|
||||||
|
currentSettings: () => AppSettings;
|
||||||
|
mainFrame: () => { x: number; y: number; width: number; height: number };
|
||||||
|
/** This window saved settings — rebroadcast them to every other window. */
|
||||||
|
onSettingsSaved: (settings: AppSettings) => void;
|
||||||
|
showAbout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single Settings window. Unlike the connection manager it is NOT modal:
|
||||||
|
* every edit is an ordinary settings save, so the other windows follow live
|
||||||
|
* while it stays open. On macOS ⌘W reaches bun as the main window's close-tab
|
||||||
|
* menu action, so index.ts asks isFocused() first and closes this window
|
||||||
|
* instead of a main-window tab.
|
||||||
|
*/
|
||||||
|
export class SettingsWindow {
|
||||||
|
private win: BrowserWindow | null = null;
|
||||||
|
private rpc: ReturnType<typeof createSettingsRPC> | null = null;
|
||||||
|
private focused = false;
|
||||||
|
|
||||||
|
constructor(private deps: SettingsWindowDeps) {}
|
||||||
|
|
||||||
|
isFocused(): boolean {
|
||||||
|
return this.focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
if (this.win) {
|
||||||
|
this.win.activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rpc = createSettingsRPC(
|
||||||
|
{ settings: this.deps.settings },
|
||||||
|
{
|
||||||
|
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
|
||||||
|
showAbout: () => this.deps.showAbout(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const main = this.deps.mainFrame();
|
||||||
|
this.win = openSecondaryWindow({
|
||||||
|
title: t("options.title"),
|
||||||
|
url: this.deps.url,
|
||||||
|
mode: "settings",
|
||||||
|
settings: this.deps.currentSettings(),
|
||||||
|
frame: {
|
||||||
|
x: Math.max(0, main.x + (main.width - SETTINGS_WIDTH) / 2),
|
||||||
|
y: Math.max(0, main.y + (main.height - SETTINGS_HEIGHT) / 2),
|
||||||
|
width: SETTINGS_WIDTH,
|
||||||
|
height: SETTINGS_HEIGHT,
|
||||||
|
},
|
||||||
|
rpc: this.rpc,
|
||||||
|
});
|
||||||
|
this.win.on("focus", () => (this.focused = true));
|
||||||
|
this.win.on("blur", () => (this.focused = false));
|
||||||
|
this.win.on("close", () => {
|
||||||
|
this.win = null;
|
||||||
|
this.rpc = null;
|
||||||
|
this.focused = false;
|
||||||
|
});
|
||||||
|
console.log("[settings] window opened");
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.win?.close(); // the close event does the cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The native title follows a language switch (the header row is the UI's own). */
|
||||||
|
retitle(): void {
|
||||||
|
this.win?.setTitle(t("options.title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastSettings(settings: AppSettings): void {
|
||||||
|
this.rpc?.send.settingsChanged({ settings });
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -13,7 +13,7 @@ import type { AppSettings } from "../shared/types";
|
|||||||
* windows flash the wrong background.
|
* windows flash the wrong background.
|
||||||
*/
|
*/
|
||||||
export function buildPreload(
|
export function buildPreload(
|
||||||
mode: "main" | "editor" | "manager",
|
mode: "main" | "editor" | "manager" | "settings",
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
ctx?: unknown,
|
ctx?: unknown,
|
||||||
): string {
|
): string {
|
||||||
@@ -76,7 +76,7 @@ export interface SecondaryWindowOptions {
|
|||||||
title: string;
|
title: string;
|
||||||
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||||
url: string;
|
url: string;
|
||||||
mode: "editor" | "manager";
|
mode: "editor" | "manager" | "settings";
|
||||||
/** Current persisted settings — baked into the preload for flash-free paint. */
|
/** Current persisted settings — baked into the preload for flash-free paint. */
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
ctx?: unknown;
|
ctx?: unknown;
|
||||||
|
|||||||
+11
-2
@@ -23,14 +23,22 @@ export const de = {
|
|||||||
manageConnections: "Verbindungen verwalten",
|
manageConnections: "Verbindungen verwalten",
|
||||||
newTab: "Neuer Tab ({mod}T)",
|
newTab: "Neuer Tab ({mod}T)",
|
||||||
closeTab: "Tab schließen ({mod}W)",
|
closeTab: "Tab schließen ({mod}W)",
|
||||||
options: "Optionen",
|
options: "Einstellungen",
|
||||||
update: "Update verfügbar",
|
update: "Update verfügbar",
|
||||||
updating: "Aktualisiere",
|
updating: "Aktualisiere",
|
||||||
restartToUpdate: "Zum Anwenden neu starten",
|
restartToUpdate: "Zum Anwenden neu starten",
|
||||||
updateTooltip: "Update verfügbar: miniSCP {version}",
|
updateTooltip: "Update verfügbar: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Optionen",
|
title: "Einstellungen",
|
||||||
|
sections: {
|
||||||
|
appearance: "Darstellung",
|
||||||
|
transfers: "Übertragungen",
|
||||||
|
editor: "Editor",
|
||||||
|
fileList: "Dateiliste",
|
||||||
|
about: "Über",
|
||||||
|
},
|
||||||
|
columns: "Sichtbare Spalten",
|
||||||
language: "Sprache",
|
language: "Sprache",
|
||||||
system: "System",
|
system: "System",
|
||||||
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
||||||
@@ -248,6 +256,7 @@ export const de = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "Über {app}",
|
about: "Über {app}",
|
||||||
|
settings: "Einstellungen…",
|
||||||
checkForUpdates: "Nach Updates suchen…",
|
checkForUpdates: "Nach Updates suchen…",
|
||||||
quit: "Beenden",
|
quit: "Beenden",
|
||||||
file: "Datei",
|
file: "Datei",
|
||||||
|
|||||||
+11
-2
@@ -27,14 +27,22 @@ export const en = {
|
|||||||
manageConnections: "Manage connections",
|
manageConnections: "Manage connections",
|
||||||
newTab: "New tab ({mod}T)",
|
newTab: "New tab ({mod}T)",
|
||||||
closeTab: "Close tab ({mod}W)",
|
closeTab: "Close tab ({mod}W)",
|
||||||
options: "Options",
|
options: "Settings",
|
||||||
update: "Update available",
|
update: "Update available",
|
||||||
updating: "Updating",
|
updating: "Updating",
|
||||||
restartToUpdate: "Restart to apply",
|
restartToUpdate: "Restart to apply",
|
||||||
updateTooltip: "Update available: miniSCP {version}",
|
updateTooltip: "Update available: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Options",
|
title: "Settings",
|
||||||
|
sections: {
|
||||||
|
appearance: "Appearance",
|
||||||
|
transfers: "Transfers",
|
||||||
|
editor: "Editor",
|
||||||
|
fileList: "File list",
|
||||||
|
about: "About",
|
||||||
|
},
|
||||||
|
columns: "Visible columns",
|
||||||
language: "Language",
|
language: "Language",
|
||||||
system: "System",
|
system: "System",
|
||||||
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
||||||
@@ -252,6 +260,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "About {app}",
|
about: "About {app}",
|
||||||
|
settings: "Settings…",
|
||||||
checkForUpdates: "Check for Updates…",
|
checkForUpdates: "Check for Updates…",
|
||||||
quit: "Quit",
|
quit: "Quit",
|
||||||
file: "File",
|
file: "File",
|
||||||
|
|||||||
+11
-2
@@ -23,14 +23,22 @@ export const es = {
|
|||||||
manageConnections: "Gestionar conexiones",
|
manageConnections: "Gestionar conexiones",
|
||||||
newTab: "Nueva pestaña ({mod}T)",
|
newTab: "Nueva pestaña ({mod}T)",
|
||||||
closeTab: "Cerrar pestaña ({mod}W)",
|
closeTab: "Cerrar pestaña ({mod}W)",
|
||||||
options: "Opciones",
|
options: "Ajustes",
|
||||||
update: "Actualización disponible",
|
update: "Actualización disponible",
|
||||||
updating: "Actualizando",
|
updating: "Actualizando",
|
||||||
restartToUpdate: "Reiniciar para aplicar",
|
restartToUpdate: "Reiniciar para aplicar",
|
||||||
updateTooltip: "Actualización disponible: miniSCP {version}",
|
updateTooltip: "Actualización disponible: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Opciones",
|
title: "Ajustes",
|
||||||
|
sections: {
|
||||||
|
appearance: "Apariencia",
|
||||||
|
transfers: "Transferencias",
|
||||||
|
editor: "Editor",
|
||||||
|
fileList: "Lista de archivos",
|
||||||
|
about: "Acerca de",
|
||||||
|
},
|
||||||
|
columns: "Columnas visibles",
|
||||||
language: "Idioma",
|
language: "Idioma",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
||||||
@@ -248,6 +256,7 @@ export const es = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "Acerca de {app}",
|
about: "Acerca de {app}",
|
||||||
|
settings: "Ajustes…",
|
||||||
checkForUpdates: "Buscar actualizaciones…",
|
checkForUpdates: "Buscar actualizaciones…",
|
||||||
quit: "Salir",
|
quit: "Salir",
|
||||||
file: "Archivo",
|
file: "Archivo",
|
||||||
|
|||||||
+11
-2
@@ -25,14 +25,22 @@ export const eu = {
|
|||||||
manageConnections: "Kudeatu konexioak",
|
manageConnections: "Kudeatu konexioak",
|
||||||
newTab: "Fitxa berria ({mod}T)",
|
newTab: "Fitxa berria ({mod}T)",
|
||||||
closeTab: "Itxi fitxa ({mod}W)",
|
closeTab: "Itxi fitxa ({mod}W)",
|
||||||
options: "Aukerak",
|
options: "Ezarpenak",
|
||||||
update: "Eguneratzea eskuragarri",
|
update: "Eguneratzea eskuragarri",
|
||||||
updating: "Eguneratzen",
|
updating: "Eguneratzen",
|
||||||
restartToUpdate: "Berrabiarazi aplikatzeko",
|
restartToUpdate: "Berrabiarazi aplikatzeko",
|
||||||
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
|
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Aukerak",
|
title: "Ezarpenak",
|
||||||
|
sections: {
|
||||||
|
appearance: "Itxura",
|
||||||
|
transfers: "Transferentziak",
|
||||||
|
editor: "Editorea",
|
||||||
|
fileList: "Fitxategi-zerrenda",
|
||||||
|
about: "Honi buruz",
|
||||||
|
},
|
||||||
|
columns: "Zutabe ikusgaiak",
|
||||||
language: "Hizkuntza",
|
language: "Hizkuntza",
|
||||||
system: "Sistema",
|
system: "Sistema",
|
||||||
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
||||||
@@ -250,6 +258,7 @@ export const eu = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "{app} (r)i buruz",
|
about: "{app} (r)i buruz",
|
||||||
|
settings: "Ezarpenak…",
|
||||||
checkForUpdates: "Bilatu eguneratzeak…",
|
checkForUpdates: "Bilatu eguneratzeak…",
|
||||||
quit: "Irten",
|
quit: "Irten",
|
||||||
file: "Fitxategia",
|
file: "Fitxategia",
|
||||||
|
|||||||
+11
-2
@@ -23,14 +23,22 @@ export const fr = {
|
|||||||
manageConnections: "Gérer les connexions",
|
manageConnections: "Gérer les connexions",
|
||||||
newTab: "Nouvel onglet ({mod}T)",
|
newTab: "Nouvel onglet ({mod}T)",
|
||||||
closeTab: "Fermer l'onglet ({mod}W)",
|
closeTab: "Fermer l'onglet ({mod}W)",
|
||||||
options: "Options",
|
options: "Réglages",
|
||||||
update: "Mise à jour disponible",
|
update: "Mise à jour disponible",
|
||||||
updating: "Mise à jour",
|
updating: "Mise à jour",
|
||||||
restartToUpdate: "Redémarrer pour appliquer",
|
restartToUpdate: "Redémarrer pour appliquer",
|
||||||
updateTooltip: "Mise à jour disponible : miniSCP {version}",
|
updateTooltip: "Mise à jour disponible : miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "Options",
|
title: "Réglages",
|
||||||
|
sections: {
|
||||||
|
appearance: "Apparence",
|
||||||
|
transfers: "Transferts",
|
||||||
|
editor: "Éditeur",
|
||||||
|
fileList: "Liste des fichiers",
|
||||||
|
about: "À propos",
|
||||||
|
},
|
||||||
|
columns: "Colonnes visibles",
|
||||||
language: "Langue",
|
language: "Langue",
|
||||||
system: "Système",
|
system: "Système",
|
||||||
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
||||||
@@ -248,6 +256,7 @@ export const fr = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "À propos de {app}",
|
about: "À propos de {app}",
|
||||||
|
settings: "Réglages…",
|
||||||
checkForUpdates: "Rechercher des mises à jour…",
|
checkForUpdates: "Rechercher des mises à jour…",
|
||||||
quit: "Quitter",
|
quit: "Quitter",
|
||||||
file: "Fichier",
|
file: "Fichier",
|
||||||
|
|||||||
+11
-2
@@ -24,14 +24,22 @@ export const ja = {
|
|||||||
manageConnections: "接続を管理",
|
manageConnections: "接続を管理",
|
||||||
newTab: "新しいタブ ({mod}T)",
|
newTab: "新しいタブ ({mod}T)",
|
||||||
closeTab: "タブを閉じる ({mod}W)",
|
closeTab: "タブを閉じる ({mod}W)",
|
||||||
options: "オプション",
|
options: "設定",
|
||||||
update: "アップデートあり",
|
update: "アップデートあり",
|
||||||
updating: "更新中",
|
updating: "更新中",
|
||||||
restartToUpdate: "再起動して適用",
|
restartToUpdate: "再起動して適用",
|
||||||
updateTooltip: "アップデートがあります: miniSCP {version}",
|
updateTooltip: "アップデートがあります: miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "オプション",
|
title: "設定",
|
||||||
|
sections: {
|
||||||
|
appearance: "外観",
|
||||||
|
transfers: "転送",
|
||||||
|
editor: "エディタ",
|
||||||
|
fileList: "ファイル一覧",
|
||||||
|
about: "情報",
|
||||||
|
},
|
||||||
|
columns: "表示する列",
|
||||||
language: "言語",
|
language: "言語",
|
||||||
system: "システム",
|
system: "システム",
|
||||||
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||||
@@ -248,6 +256,7 @@ export const ja = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "{app} について",
|
about: "{app} について",
|
||||||
|
settings: "設定…",
|
||||||
checkForUpdates: "アップデートを確認…",
|
checkForUpdates: "アップデートを確認…",
|
||||||
quit: "終了",
|
quit: "終了",
|
||||||
file: "ファイル",
|
file: "ファイル",
|
||||||
|
|||||||
+11
-2
@@ -24,14 +24,22 @@ export const zh = {
|
|||||||
manageConnections: "管理连接",
|
manageConnections: "管理连接",
|
||||||
newTab: "新建标签页 ({mod}T)",
|
newTab: "新建标签页 ({mod}T)",
|
||||||
closeTab: "关闭标签页 ({mod}W)",
|
closeTab: "关闭标签页 ({mod}W)",
|
||||||
options: "选项",
|
options: "设置",
|
||||||
update: "有可用更新",
|
update: "有可用更新",
|
||||||
updating: "正在更新",
|
updating: "正在更新",
|
||||||
restartToUpdate: "重启以应用",
|
restartToUpdate: "重启以应用",
|
||||||
updateTooltip: "有可用更新:miniSCP {version}",
|
updateTooltip: "有可用更新:miniSCP {version}",
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
title: "选项",
|
title: "设置",
|
||||||
|
sections: {
|
||||||
|
appearance: "外观",
|
||||||
|
transfers: "传输",
|
||||||
|
editor: "编辑器",
|
||||||
|
fileList: "文件列表",
|
||||||
|
about: "关于",
|
||||||
|
},
|
||||||
|
columns: "显示的列",
|
||||||
language: "语言",
|
language: "语言",
|
||||||
system: "系统",
|
system: "系统",
|
||||||
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||||
@@ -248,6 +256,7 @@ export const zh = {
|
|||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
about: "关于 {app}",
|
about: "关于 {app}",
|
||||||
|
settings: "设置…",
|
||||||
checkForUpdates: "检查更新…",
|
checkForUpdates: "检查更新…",
|
||||||
quit: "退出",
|
quit: "退出",
|
||||||
file: "文件",
|
file: "文件",
|
||||||
|
|||||||
+6
-3
@@ -26,9 +26,9 @@ export interface EditorOpenContext {
|
|||||||
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
||||||
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
||||||
//
|
//
|
||||||
// ONE schema serves both window kinds (main + editor): electrobun allows
|
// ONE schema serves every window kind (main, editor, manager, settings):
|
||||||
// partial handler sets, and messages nobody listens to are silently dropped,
|
// electrobun allows partial handler sets, and messages nobody listens to are
|
||||||
// so each side only implements/hears what applies to it.
|
// silently dropped, so each side only implements/hears what applies to it.
|
||||||
export type AppRPC = {
|
export type AppRPC = {
|
||||||
bun: {
|
bun: {
|
||||||
requests: {
|
requests: {
|
||||||
@@ -154,6 +154,9 @@ export type AppRPC = {
|
|||||||
managerConnect: { params: { id: string }; response: Result };
|
managerConnect: { params: { id: string }; response: Result };
|
||||||
managerOpenStack: { params: { id: string }; response: Result };
|
managerOpenStack: { params: { id: string }; response: Result };
|
||||||
managerClose: { params: Record<string, never>; response: Result };
|
managerClose: { params: Record<string, never>; response: Result };
|
||||||
|
// settings window
|
||||||
|
/** Main window: open (or focus) the single Settings window. */
|
||||||
|
openSettings: { params: Record<string, never>; response: Result };
|
||||||
// auto-updater (electrobun Updater)
|
// auto-updater (electrobun Updater)
|
||||||
/** Main window: re-run the update check on demand (menu item). */
|
/** Main window: re-run the update check on demand (menu item). */
|
||||||
checkForUpdate: {
|
checkForUpdate: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform';
|
|||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
|
|
||||||
/** Optional list columns, in display order (Name is fixed). */
|
/** Optional list columns, in display order (Name is fixed). */
|
||||||
const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
|
export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
type?: 'separator';
|
type?: 'separator';
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
tdesignDelete,
|
tdesignDelete,
|
||||||
tdesignDownload,
|
tdesignDownload,
|
||||||
tdesignEdit,
|
tdesignEdit,
|
||||||
tdesignEllipsis,
|
|
||||||
tdesignFile,
|
tdesignFile,
|
||||||
tdesignFile1,
|
tdesignFile1,
|
||||||
tdesignFileAdd,
|
tdesignFileAdd,
|
||||||
@@ -54,7 +53,6 @@ export const APP_ICONS = {
|
|||||||
tdesignDelete,
|
tdesignDelete,
|
||||||
tdesignDownload,
|
tdesignDownload,
|
||||||
tdesignEdit,
|
tdesignEdit,
|
||||||
tdesignEllipsis,
|
|
||||||
tdesignFile,
|
tdesignFile,
|
||||||
tdesignFile1,
|
tdesignFile1,
|
||||||
tdesignFileAdd,
|
tdesignFileAdd,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
__APP_PLATFORM?: string;
|
__APP_PLATFORM?: string;
|
||||||
/** Which root component this window boots. */
|
/** Which root component this window boots. */
|
||||||
__APP_MODE?: 'main' | 'editor' | 'manager';
|
__APP_MODE?: 'main' | 'editor' | 'manager' | 'settings';
|
||||||
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
/** Window-kind specific context (EditorOpenContext for editor windows). */
|
||||||
__APP_CTX?: unknown;
|
__APP_CTX?: unknown;
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ export const IS_LINUX = PLATFORM === 'linux';
|
|||||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||||
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||||
|
|
||||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
|
||||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||||
|
|
||||||
export const EDITOR_CONTEXT =
|
export const EDITOR_CONTEXT =
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type ShortcutId =
|
|||||||
| 'cycle-tab'
|
| 'cycle-tab'
|
||||||
| 'cycle-tab-back'
|
| 'cycle-tab-back'
|
||||||
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
|
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
|
||||||
|
| 'open-settings'
|
||||||
// editor window
|
// editor window
|
||||||
| 'save'
|
| 'save'
|
||||||
| 'find'
|
| 'find'
|
||||||
@@ -29,11 +30,11 @@ export interface ShortcutMatch {
|
|||||||
digit?: number;
|
digit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which keymap a window consults. 'manager' answers only to the any-window
|
/** Which keymap a window consults. 'manager' and 'settings' answer only to the
|
||||||
* shortcuts (scale). Escape/Tab stay component-local: their meaning is
|
* any-window shortcuts (scale). Escape/Tab stay component-local: their meaning
|
||||||
* contextual state (find bar open? close prompt up? which pane?), not a
|
* is contextual state (find bar open? close prompt up? which pane?), not a
|
||||||
* global binding. */
|
* global binding. */
|
||||||
export type ShortcutScope = 'main' | 'editor' | 'manager';
|
export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The keyboard layer: modifier helpers + one declarative keymap.
|
* The keyboard layer: modifier helpers + one declarative keymap.
|
||||||
@@ -83,9 +84,11 @@ export class ShortcutsService {
|
|||||||
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
|
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === 'manager') return null; // any-window shortcuts only
|
if (scope === 'manager' || scope === 'settings') return null; // any-window shortcuts only
|
||||||
|
|
||||||
if (scope === 'main') {
|
if (scope === 'main') {
|
||||||
|
// on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path
|
||||||
|
if (mod && key === ',') return { id: 'open-settings' };
|
||||||
if (mod && key === 't') return { id: 'new-tab' };
|
if (mod && key === 't') return { id: 'new-tab' };
|
||||||
if (mod && key === 'w') return { id: 'close-tab' };
|
if (mod && key === 'w') return { id: 'close-tab' };
|
||||||
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
|
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ export class App {
|
|||||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
||||||
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
||||||
break;
|
break;
|
||||||
|
case 'open-settings':
|
||||||
|
void this.rpc.request.openSettings({});
|
||||||
|
break;
|
||||||
case 'scale-up':
|
case 'scale-up':
|
||||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
import { ConnectionManager } from './connection-manager/connection-manager';
|
import { ConnectionManager } from './connection-manager/connection-manager';
|
||||||
import { EditorWindow } from './editor-window/editor-window';
|
import { EditorWindow } from './editor-window/editor-window';
|
||||||
|
import { SettingsWindow } from './settings-window/settings-window';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The three window roots, keyed by APP_MODE — main.ts picks one. There is no
|
* The four 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,
|
* title-bar component: the native window frame is kept (hiddenInset on macOS,
|
||||||
* which is what IS_MAC_CHROME accounts for).
|
* which is what IS_MAC_CHROME accounts for).
|
||||||
*/
|
*/
|
||||||
@@ -11,4 +12,5 @@ export const SHAD_ROOTS = {
|
|||||||
main: App,
|
main: App,
|
||||||
manager: ConnectionManager,
|
manager: ConnectionManager,
|
||||||
editor: EditorWindow,
|
editor: EditorWindow,
|
||||||
|
settings: SettingsWindow,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<div class="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||||
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||||
|
<header
|
||||||
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
||||||
|
[class.h-10]="isMacChrome"
|
||||||
|
[class.pl-24]="isMacChrome"
|
||||||
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||||
|
[class.h-9]="!isMacChrome"
|
||||||
|
[class.pl-3]="!isMacChrome"
|
||||||
|
>
|
||||||
|
<ng-icon name="tdesignSetting" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<h1 class="text-sm font-semibold">{{ i18n.t('options.title') }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<!-- section list -->
|
||||||
|
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2">
|
||||||
|
@for (s of sections; track s) {
|
||||||
|
<button
|
||||||
|
(click)="section.set(s)"
|
||||||
|
class="rounded-md px-2.5 py-1.5 text-left text-xs hover:bg-accent hover:text-foreground"
|
||||||
|
[class.bg-accent]="section() === s"
|
||||||
|
[class.font-medium]="section() === s"
|
||||||
|
[class.text-foreground]="section() === s"
|
||||||
|
[class.text-muted-foreground]="section() !== s"
|
||||||
|
>
|
||||||
|
{{ i18n.t('options.sections.' + s) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- the chosen section's rows -->
|
||||||
|
<main class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<h2 class="text-sm font-semibold">{{ i18n.t('options.sections.' + section()) }}</h2>
|
||||||
|
<div class="mt-4 flex max-w-md flex-col gap-3">
|
||||||
|
@switch (section()) {
|
||||||
|
@case ('appearance') {
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ 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>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ 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="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ 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="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ 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-background]="settings.accent() === tone.value"
|
||||||
|
></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), this window included. -->
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('transfers') {
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ 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="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ 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="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="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('editor') {
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs">{{ 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="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="tdesignClose" size="0.7rem" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('fileList') {
|
||||||
|
<span class="text-xs">{{ i18n.t('options.columns') }}</span>
|
||||||
|
<div class="flex flex-col gap-2 pl-3">
|
||||||
|
@for (key of columnKeys; track key) {
|
||||||
|
<label class="flex items-center gap-2 text-xs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-primary"
|
||||||
|
[checked]="settings.columns().includes(key)"
|
||||||
|
(change)="settings.toggleColumn(key)"
|
||||||
|
/>
|
||||||
|
{{ i18n.t('columns.' + key) }}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ('about') {
|
||||||
|
<!-- the old popover footer: opens the native About box -->
|
||||||
|
<button
|
||||||
|
(click)="showAbout()"
|
||||||
|
class="self-start rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
{{ appName }} · {{ buildLabel }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Component, computed, inject, signal } 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 { COLUMN_KEYS } from '@core/context-menu.service';
|
||||||
|
import { I18nService } from '@core/i18n.service';
|
||||||
|
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
||||||
|
import { RpcService } from '@core/rpc.service';
|
||||||
|
import { SettingsService } from '@core/settings.service';
|
||||||
|
import { ShortcutsService } from '@core/shortcuts.service';
|
||||||
|
|
||||||
|
const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'about'] as const;
|
||||||
|
type Section = (typeof SECTIONS)[number];
|
||||||
|
|
||||||
|
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
|
||||||
|
// pages (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 TRANSFER_MODES = ['single', 'multi'] as const;
|
||||||
|
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root of the Settings window (APP_MODE 'settings'). Every control is a plain
|
||||||
|
* SettingsService setter: the save goes to bun, which rebroadcasts it, so the
|
||||||
|
* other windows follow live while this one stays open.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
imports: [NgIcon],
|
||||||
|
templateUrl: './settings-window.html',
|
||||||
|
host: {
|
||||||
|
'(window:keydown)': 'onWindowKeydown($event)',
|
||||||
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class SettingsWindow {
|
||||||
|
readonly settings = inject(SettingsService);
|
||||||
|
readonly i18n = inject(I18nService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
private readonly shortcuts = inject(ShortcutsService);
|
||||||
|
|
||||||
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
|
readonly sections = SECTIONS;
|
||||||
|
readonly section = signal<Section>('appearance');
|
||||||
|
readonly localeOptions = SUPPORTED;
|
||||||
|
readonly localeLabels = LOCALE_LABELS;
|
||||||
|
readonly themes = THEMES;
|
||||||
|
readonly darkTones = DARK_TONES;
|
||||||
|
readonly accents = ACCENTS;
|
||||||
|
readonly speedUnits = SPEED_UNITS;
|
||||||
|
readonly transferModes = TRANSFER_MODES;
|
||||||
|
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
||||||
|
readonly columnKeys = COLUMN_KEYS;
|
||||||
|
readonly appName = BRANDING.appName;
|
||||||
|
readonly buildLabel = BRANDING.buildLabel;
|
||||||
|
/** "100%" for the stepper readout; the value itself lives in settings. */
|
||||||
|
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
|
||||||
|
|
||||||
|
/** "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, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async pickEditorApp(): Promise<void> {
|
||||||
|
const res = await this.rpc.request.pickEditorApp({});
|
||||||
|
if (res.ok && res.path) this.settings.setEditorApp(res.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
showAbout(): void {
|
||||||
|
void this.rpc.request.showAbout({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any-window shortcuts (interface scale); ⌘W arrives as a menu action bun-side. */
|
||||||
|
onWindowKeydown(event: KeyboardEvent): void {
|
||||||
|
const match = this.shortcuts.resolve(event, 'settings');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,12 @@
|
|||||||
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
|
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/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 { ContextMenuService } from '@core/context-menu.service';
|
||||||
import { I18nService } from '@core/i18n.service';
|
import { I18nService } from '@core/i18n.service';
|
||||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||||
import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||||
import { RpcService } from '@core/rpc.service';
|
import { RpcService } from '@core/rpc.service';
|
||||||
import { SettingsService } from '@core/settings.service';
|
|
||||||
import { UpdaterService } from '@core/updater.service';
|
import { UpdaterService } from '@core/updater.service';
|
||||||
|
|
||||||
const THEMES = ['system', 'light', 'dark'] as const;
|
|
||||||
const DARK_TONES = ['dark', 'darker'] as const;
|
|
||||||
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
|
|
||||||
// cards (the live --primary values would vanish against one of them). Names are
|
|
||||||
// translated via options.accentNames.<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 TRANSFER_MODES = ['single', 'multi'] as const;
|
|
||||||
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tab-bar',
|
selector: 'app-tab-bar',
|
||||||
imports: [NgIcon],
|
imports: [NgIcon],
|
||||||
@@ -154,302 +128,25 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
{{ i18n.t('tabBar.connections') }}
|
{{ i18n.t('tabBar.connections') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- global options -->
|
<!-- settings (own native window) -->
|
||||||
<button
|
<button
|
||||||
(click)="settingsOpen.set(!settingsOpen())"
|
(click)="openSettings()"
|
||||||
[title]="i18n.t('tabBar.options')"
|
[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"
|
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="tdesignEllipsis" size="0.8rem" />
|
<ng-icon name="tdesignSetting" size="0.9rem" />
|
||||||
</button>
|
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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="tdesignClose" 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>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class TabBar {
|
export class TabBar {
|
||||||
readonly panels = inject(PanelsService);
|
readonly panels = inject(PanelsService);
|
||||||
readonly settings = inject(SettingsService);
|
|
||||||
readonly i18n = inject(I18nService);
|
readonly i18n = inject(I18nService);
|
||||||
readonly updater = inject(UpdaterService);
|
readonly updater = inject(UpdaterService);
|
||||||
private readonly contextMenu = inject(ContextMenuService);
|
private readonly contextMenu = inject(ContextMenuService);
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
readonly settingsOpen = signal(false);
|
|
||||||
readonly localeOptions = SUPPORTED;
|
|
||||||
readonly localeLabels = LOCALE_LABELS;
|
|
||||||
readonly isMacChrome = IS_MAC_CHROME;
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
readonly modLabel = MOD_LABEL;
|
readonly modLabel = MOD_LABEL;
|
||||||
readonly appName = BRANDING.appName;
|
|
||||||
readonly buildLabel = BRANDING.buildLabel;
|
|
||||||
readonly themes = THEMES;
|
|
||||||
readonly darkTones = DARK_TONES;
|
|
||||||
readonly accents = ACCENTS;
|
|
||||||
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
|
// drag & drop reordering
|
||||||
readonly draggedId = signal<number | null>(null);
|
readonly draggedId = signal<number | null>(null);
|
||||||
@@ -506,18 +203,14 @@ export class TabBar {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "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 {
|
openManager(): void {
|
||||||
void this.rpc.request.openConnectionManager({});
|
void this.rpc.request.openConnectionManager({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openSettings(): void {
|
||||||
|
void this.rpc.request.openSettings({});
|
||||||
|
}
|
||||||
|
|
||||||
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
||||||
onUpdateClick(): void {
|
onUpdateClick(): void {
|
||||||
const state = this.updater.state();
|
const state = this.updater.state();
|
||||||
@@ -525,15 +218,6 @@ export class TabBar {
|
|||||||
else if (state === 'ready') this.updater.apply();
|
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). */
|
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
||||||
dropIndicatorShadow(index: number): string | null {
|
dropIndicatorShadow(index: number): string | null {
|
||||||
const target = this.dropTarget();
|
const target = this.dropTarget();
|
||||||
|
|||||||
+2
-4
@@ -3,7 +3,5 @@ import { appConfig } from '@core/app.config';
|
|||||||
import { APP_MODE } from '@core/platform';
|
import { APP_MODE } from '@core/platform';
|
||||||
import { SHAD_ROOTS } from './app/skins/shadlike';
|
import { SHAD_ROOTS } from './app/skins/shadlike';
|
||||||
|
|
||||||
// One bundle, three window kinds: the preload global picks the root component.
|
// One bundle, four window kinds: the preload global picks the root component.
|
||||||
const root =
|
bootstrapApplication(SHAD_ROOTS[APP_MODE], appConfig).catch((err) => console.error(err));
|
||||||
APP_MODE === 'editor' ? SHAD_ROOTS.editor : APP_MODE === 'manager' ? SHAD_ROOTS.manager : SHAD_ROOTS.main;
|
|
||||||
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
|
|
||||||
|
|||||||
Reference in New Issue
Block a user