Compare commits

..
10 Commits
Author SHA1 Message Date
JokinandClaude Fable 5 ba0b2434fb Rename to miniSCP; migrate legacy data dirs on boot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:53:26 +02:00
JokinandClaude Fable 5 320080de6d Blue as default accent; slightly smaller swatches
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:40:49 +02:00
JokinandClaude Fable 5 44f077380f File-type icon colors with Plain/Multi setting (multi default)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:31:10 +02:00
JokinandClaude Fable 5 2f4bc63ac8 Accent tone setting with macOS-style swatches (indigo default)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:26:35 +02:00
JokinandClaude Fable 5 c7b0418904 Jump to tab with Cmd+1-9 (Ctrl elsewhere), Firefox style
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 00:06:55 +02:00
JokinandClaude Fable 5 aed565e424 Key-file picker in the connection manager
Choose... button beside the private-key field opens the native file dialog
starting in ~/.ssh (Utils.openFileDialog via a pickKeyFile RPC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:50:44 +02:00
JokinandClaude Fable 5 a0fc96fc44 Quit guard during transfers + background completion notifications
- before-quit is blocked while jobs are queued/scanning/running; a native
  message box asks 'N transfers are still running - quit anyway?' (Cancel
  default) and only an explicit Quit Anyway proceeds via Utils.quit()
- when the window is unfocused, finished transfers raise a native
  notification (silent for completions, sound + error message for failures)
- engine.hasActiveJobs()/activeJobCount() (truth table verified headless)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:49:36 +02:00
JokinandClaude Fable 5 fbbde16f35 Right-click context menus on file panels
Native menus (electrobun ContextMenu) on rows and empty panel space. Rows:
Open (dirs navigate; local files open with the default app via Utils.openPath),
Edit, Copy to Other Panel, Rename, Permissions, Copy Path (works for remote
paths too), Reveal in Finder (local, Utils.showItemInFolder), Move to Trash
(local, Utils.moveToTrash — tooltip notes Finder Restore doesn't apply) and
Delete. Empty space / '..': New Folder, New File, Refresh, Reveal in Finder.

Right-click parks the cursor on the row (keeping the selection when clicking
inside it). Local shell actions run bun-side directly; everything else routes
through the existing menuAction channel back to the stored click target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:48:23 +02:00
JokinandClaude Fable 5 9737cad6de Type-to-filter in file panels
Typing while the list is focused builds a filter query: a bar appears under
the column headers (search icon, query, live match count, Esc hint, clear
button) and the listing narrows to substring matches — '..' always stays.
Backspace edits the query, Escape clears it, cursor parks on the first match,
arrows/Enter keep working (the list never loses focus), and Space keeps its
select meaning until a query exists. Filter resets on directory change and
survives refresh; operations act only on visible (filtered) selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:05:23 +02:00
JokinandClaude Fable 5 41f9e14cad Tray glyph: thinner strokes, outlined lock for legibility
1.8 -> 1.5 stroke weight and the lock body switches from solid fill to an
outline — the filled version read as a blob at menu-bar size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:00:06 +02:00
25 changed files with 746 additions and 48 deletions
+10 -9
View File
@@ -1,4 +1,4 @@
# BunSCP
# miniSCP
A cross-platform, dual-panel SFTP/local file manager — a small WinSCP-style
commander built on **Bun**, **Electrobun**, and **Angular**.
@@ -90,7 +90,7 @@ Bun main process (`src/bun/**`, RPC schema) trigger an app relaunch.
## Build a packaged app
```bash
bun run build # ng build (ui/dist) + electrobun build -> build/*/BunSCP*.app
bun run build # ng build (ui/dist) + electrobun build -> build/*/miniSCP*.app
```
The Angular bundle is copied into the app under `views://app`. The macOS icon is
@@ -107,8 +107,9 @@ Everything user-facing reads from **[`src/shared/branding.ts`](src/shared/brandi
```ts
export const BRANDING = {
appName: "BunSCP", // window title, bundle name, app-data folder
identifier: "es.teknes.bunscp",
appName: "miniSCP", // window title, bundle name, app-data folder
identifier: "es.teknes.miniscp",
legacyNames: ["BunSCP"], // previous names; their data dirs migrate on boot
version: "0.1.0",
icon: "assets/icon.iconset",
} as const;
@@ -120,14 +121,14 @@ To change the icon: edit `assets/logo.svg` (app icon) and
tray template image, and the tray progress frames from the SVGs
(`@resvg/resvg-js`, dev-only dependency).
> Renaming `appName` changes the app-data directory, so existing saved
> connections won't be found under the new name — copy `connections.json` across
> if you need them.
> Renaming `appName` changes the app-data directory. Add the old name to
> `legacyNames` and the app moves the previous folder (connections, settings,
> edit temp files) into place on the next boot.
## Where connections are stored
Saved connections live in a plain JSON file in the OS app-data directory
(macOS: `~/Library/Application Support/BunSCP/connections.json`), written with
(macOS: `~/Library/Application Support/miniSCP/connections.json`), written with
`0600` permissions.
> ⚠️ **Passwords are stored in plaintext** in this file (a deliberate v1
@@ -148,7 +149,7 @@ Saved connections live in a plain JSON file in the OS app-data directory
## Same-file protection
Copying a file or folder onto itself would truncate it to zero bytes (the
destination is opened for writing before the source is read). BunSCP blocks
destination is opened for writing before the source is read). miniSCP blocks
this at several layers:
- **Same session / same host**: every connection carries a filesystem identity
Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 B

After

Width:  |  Height:  |  Size: 621 B

+4 -3
View File
@@ -1,11 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<!-- BunSCP tray glyph: black + alpha only (macOS template image).
Same geometry as logo.svg, slightly bolder for menu-bar sizes. -->
<g fill="none" stroke="#000" stroke-width="1.8"
<g fill="none" stroke="#000" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M8.2 8.2 4.6 4.6m0 0v4.2m0-4.2h4.2"/>
<path d="m15.8 15.8 3.6 3.6m0 0v-4.2m0 4.2h-4.2"/>
<path d="M13.3 11.2V9.9c0-1.95-2.6-1.95-2.6 0v1.3"/>
<rect x="9.4" y="11.2" width="5.2" height="4.6" rx="1.1" fill="#000" stroke="#000"/>
<!-- lock: outlined so the shackle + body silhouette stays readable small -->
<path d="M13.55 11V9.7c0-2.1-3.1-2.1-3.1 0V11"/>
<rect x="9.1" y="11" width="5.8" height="5" rx="1.2"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 573 B

After

Width:  |  Height:  |  Size: 621 B

+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "bunscp",
"name": "miniscp",
"version": "0.1.0",
"private": true,
"type": "module",
+91
View File
@@ -0,0 +1,91 @@
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
export interface RowMenuContext {
path: string;
name: string;
type: "file" | "dir" | "symlink";
isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space
}
/**
* Native right-click menus for the file panels. Local-only items run directly
* here via Utils (reveal, open with default app, copy path); everything else
* forwards to the webview through the menuAction channel — the UI kept its own
* context (which panel/row was clicked) when it requested the menu.
*/
export class PanelContextMenu {
private context: RowMenuContext | null = null;
/** Forwards actions the UI must handle (ctx-open, ctx-delete, ...). */
onAction: (action: string) => void = () => {};
constructor() {
ContextMenu.on("context-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action;
const ctx = this.context;
if (!action || !ctx) return;
switch (action) {
case "ctx-reveal":
Utils.showItemInFolder(ctx.path);
break;
case "ctx-open-default":
Utils.openPath(ctx.path);
break;
case "ctx-copy-path":
Utils.clipboardWriteText(ctx.path);
break;
default:
if (action.startsWith("ctx-")) this.onAction(action);
}
});
}
show(context: RowMenuContext): void {
this.context = context;
ContextMenu.showContextMenu(this.build(context));
}
private build(ctx: RowMenuContext): ApplicationMenuItemConfig[] {
const items: ApplicationMenuItemConfig[] = [];
if (!ctx.hasEntry || ctx.name === "..") {
// empty space (or the parent row): folder-level actions
items.push({ label: "New Folder", action: "ctx-new-folder" });
items.push({ label: "New File", action: "ctx-new-file" });
items.push({ type: "separator" });
items.push({ label: "Refresh", action: "ctx-refresh" });
if (ctx.isLocal) {
items.push({ type: "separator" });
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
}
return items;
}
if (ctx.type === "dir") {
items.push({ label: "Open", action: "ctx-open" });
} else {
if (ctx.isLocal) {
items.push({ label: "Open", action: "ctx-open-default", tooltip: "Open with the default application" });
}
items.push({ label: "Edit", action: "ctx-edit", accelerator: "e" });
}
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
items.push({ label: "Rename", action: "ctx-rename" });
items.push({ label: "Permissions", action: "ctx-chmod" });
items.push({ type: "separator" });
items.push({ label: "Copy Path", action: "ctx-copy-path" });
if (ctx.isLocal) {
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
}
items.push({ type: "separator" });
if (ctx.isLocal) {
items.push({
label: "Move to Trash",
action: "ctx-trash",
tooltip: "Finder's Restore is not available for items trashed from here",
});
}
items.push({ label: "Delete", action: "ctx-delete" });
return items;
}
}
+61 -4
View File
@@ -1,11 +1,13 @@
import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun";
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun";
import { BRANDING } from "../shared/branding";
import { effectiveConcurrency } from "../shared/types";
import { PanelContextMenu } from "./context-menu";
import { EditManager } from "./edits";
import { SessionRegistry } from "./fs/registry";
import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers";
import { ConnectionStore } from "./store/connections";
import { migrateLegacyDataDir } from "./store/paths";
import { SettingsStore } from "./store/settings";
import { AppTray } from "./tray";
import { TransferEngine } from "./transfers/engine";
@@ -30,16 +32,20 @@ async function getMainViewUrl(): Promise<string> {
}
}
// after a rebrand, adopt the previous name's data dir before stores read it
migrateLegacyDataDir();
const registry = new SessionRegistry();
const engine = new TransferEngine(registry);
const store = new ConnectionStore();
const settings = new SettingsStore();
const edits = new EditManager(registry, engine, settings);
const contextMenu = new PanelContextMenu();
const events = {
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
connectionsChanged: () => {},
};
const rpc = createAppRPC({ registry, engine, store, settings, edits, events });
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
const url = await getMainViewUrl();
const isMac = process.platform === "darwin";
@@ -60,13 +66,64 @@ const win = new BrowserWindow({
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {}),
// lets the UI pick the fused layout before first paint
preload: `window.__BUNSCP_PLATFORM=${JSON.stringify(process.platform)};`,
preload: `window.__APP_PLATFORM=${JSON.stringify(process.platform)};`,
rpc,
});
new AppTray(win);
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
// notify on finished transfers while the window is in the background
let windowFocused = true;
win.on("focus", () => (windowFocused = true));
win.on("blur", () => (windowFocused = false));
const notifiedStates = new Map<string, string>();
engine.onUpdate = (snapshot) => {
rpc.send.transferUpdate(snapshot);
const previous = notifiedStates.get(snapshot.id);
if (previous !== snapshot.state) {
notifiedStates.set(snapshot.id, snapshot.state);
if (!windowFocused && (snapshot.state === "completed" || snapshot.state === "failed")) {
Utils.showNotification({
title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed",
body:
snapshot.state === "completed"
? snapshot.name
: `${snapshot.name}: ${snapshot.error ?? "unknown error"}`,
silent: snapshot.state === "completed",
});
}
if (snapshot.state === "completed" || snapshot.state === "failed" || snapshot.state === "cancelled") {
// keep the map from growing over long sessions
setTimeout(() => notifiedStates.delete(snapshot.id), 60_000);
}
}
};
// don't quit out from under running transfers
let quitConfirmed = false;
Electrobun.events.on("before-quit", (event) => {
if (quitConfirmed || !engine.hasActiveJobs()) return;
(event as { response: { allow: boolean } }).response = { allow: false };
void (async () => {
const count = engine.activeJobCount();
const { response } = await Utils.showMessageBox({
type: "warning",
title: BRANDING.appName,
message: `${count} transfer${count === 1 ? " is" : "s are"} still running.`,
detail: "Quitting now will abort them.",
buttons: ["Quit Anyway", "Cancel"],
defaultId: 1,
cancelId: 1,
});
if (response === 0) {
quitConfirmed = true;
Utils.quit();
}
})();
});
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
events.connectionsChanged = () => void applyApplicationMenu(store);
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
+38 -1
View File
@@ -1,6 +1,7 @@
import { BrowserView, Utils } from "electrobun/bun";
import type { AppRPC } from "../shared/rpc";
import { effectiveConcurrency, type Result } from "../shared/types";
import type { PanelContextMenu } from "./context-menu";
import { readFileText, writeFileText, type EditManager } from "./edits";
import type { SessionRegistry } from "./fs/registry";
import { SftpProvider } from "./fs/sftp";
@@ -19,6 +20,7 @@ export interface AppDeps {
store: ConnectionStore;
settings: SettingsStore;
edits: EditManager;
contextMenu: PanelContextMenu;
/** Assigned after the rpc object exists (it needs rpc.send). */
events: {
sessionClosed: (info: { sessionId: string; reason: string }) => void;
@@ -34,7 +36,15 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
}
}
export function createAppRPC({ registry, engine, store, settings, edits, events }: AppDeps) {
export function createAppRPC({
registry,
engine,
store,
settings,
edits,
contextMenu,
events,
}: AppDeps) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
@@ -193,6 +203,20 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
return { path };
}),
pickKeyFile: () =>
wrap(async () => {
const { homedir } = await import("node:os");
const picked = await Utils.openFileDialog({
startingFolder: `${homedir()}/.ssh`,
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
}),
getEditLeftovers: () =>
wrap(async () => ({ names: await edits.listLeftovers() })),
@@ -201,6 +225,19 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
await edits.clearLeftovers();
return {};
}),
// ---- context menu + local shell integration ----
showRowMenu: (params) =>
wrap(async () => {
contextMenu.show(params);
return {};
}),
moveToTrash: ({ path }) =>
wrap(async () => {
Utils.moveToTrash(path);
return {};
}),
},
},
});
+24 -7
View File
@@ -1,18 +1,35 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { BRANDING } from "../../shared/branding";
export function appDataDir(): string {
function dirFor(appName: string): string {
const home = os.homedir();
switch (process.platform) {
case "darwin":
return path.join(home, "Library", "Application Support", BRANDING.appName);
return path.join(home, "Library", "Application Support", appName);
case "win32":
return path.join(
process.env.APPDATA ?? path.join(home, "AppData", "Roaming"),
BRANDING.appName,
);
return path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), appName);
default:
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), BRANDING.appName);
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), appName);
}
}
export function appDataDir(): string {
return dirFor(BRANDING.appName);
}
/** One-time move of a previous name's data dir (connections, settings, edits)
* after a rebrand. Runs at boot, before anything touches the stores. */
export function migrateLegacyDataDir(): void {
const current = appDataDir();
if (fs.existsSync(current)) return;
for (const legacy of BRANDING.legacyNames) {
const old = dirFor(legacy);
if (!fs.existsSync(old)) continue;
fs.mkdirSync(path.dirname(current), { recursive: true });
fs.renameSync(old, current);
console.log(`[store] migrated data dir ${legacy} -> ${BRANDING.appName}`);
return;
}
}
+22 -2
View File
@@ -1,11 +1,14 @@
import { pipeline } from "node:stream/promises";
import type { Readable } from "node:stream";
import { BRANDING } from "../../shared/branding";
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
import type { FileSystemProvider } from "../fs/provider";
import type { SessionRegistry } from "../fs/registry";
import { walkTree } from "./walk";
const PROGRESS_INTERVAL_MS = 100;
// same-fs probe temp files stay attributable to the app that wrote them
const PROBE_PREFIX = `.${BRANDING.appName.toLowerCase()}-probe-`;
// workers per job; the shared slot budget is the real limit
const MAX_WORKERS_PER_JOB = 8;
@@ -146,6 +149,22 @@ export class TransferEngine {
if (job && TERMINAL.includes(job.snapshot.state)) this.jobs.delete(jobId);
}
/** True while any job is queued, scanning or running (quit guard). */
hasActiveJobs(): boolean {
for (const job of this.jobs.values()) {
if (!TERMINAL.includes(job.snapshot.state)) return true;
}
return false;
}
activeJobCount(): number {
let count = 0;
for (const job of this.jobs.values()) {
if (!TERMINAL.includes(job.snapshot.state)) count++;
}
return count;
}
/** Fail every non-terminal job that touches a dead session. */
failSession(sessionId: string, reason: string): void {
for (const job of this.jobs.values()) {
@@ -343,7 +362,8 @@ export class TransferEngine {
dir: string,
): Promise<boolean> {
const token = crypto.randomUUID();
const probePath = dst.join(dir, `.bunscp-probe-${token.slice(0, 8)}`);
const probeName = `${PROBE_PREFIX}${token.slice(0, 8)}`;
const probePath = dst.join(dir, probeName);
try {
await new Promise<void>((resolve, reject) => {
const stream = dst.createWriteStream(probePath);
@@ -352,7 +372,7 @@ export class TransferEngine {
});
try {
const chunks: Buffer[] = [];
for await (const chunk of src.createReadStream(src.join(dir, `.bunscp-probe-${token.slice(0, 8)}`))) {
for await (const chunk of src.createReadStream(src.join(dir, probeName))) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
const seen = Buffer.concat(chunks).toString("utf8") === token;
+5 -2
View File
@@ -1,8 +1,11 @@
// The single place to rebrand the app. Everything (window title, bundle name,
// app-data directory, UI header, icon) reads from here.
export const BRANDING = {
appName: "BunSCP",
identifier: "es.teknes.bunscp",
appName: "miniSCP",
identifier: "es.teknes.miniscp",
// Previous app names: their app-data folders are migrated on boot
// (see migrateLegacyDataDir in src/bun/store/paths.ts).
legacyNames: ["BunSCP"],
version: "0.1.0",
// macOS build converts this .iconset folder into AppIcon.icns.
// Regenerate from assets/icon.png with scripts/gen-iconset.sh.
+16
View File
@@ -91,11 +91,27 @@ export type AppRPC = {
params: Record<string, never>;
response: Result<{ path: string | null }>;
};
pickKeyFile: {
params: Record<string, never>;
response: Result<{ path: string | null }>;
};
getEditLeftovers: {
params: Record<string, never>;
response: Result<{ names: string[] }>;
};
clearEditLeftovers: { params: Record<string, never>; response: Result };
// context menu + local shell integration
showRowMenu: {
params: {
path: string;
name: string;
type: EntryType;
isLocal: boolean;
hasEntry: boolean;
};
response: Result;
};
moveToTrash: { params: { path: string }; response: Result };
};
messages: Record<never, never>;
};
+16
View File
@@ -41,8 +41,22 @@ export interface ConnectionsFile {
connections: SavedConnection[];
}
export type AccentTone =
| "graphite"
| "blue"
| "indigo"
| "purple"
| "rose"
| "orange"
| "green"
| "teal";
export interface AppSettings {
theme: "system" | "light" | "dark";
/** Accent tone for chrome and selection tint; graphite = neutral zinc. */
accent: AccentTone;
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
iconColors: "plain" | "multi";
speedUnit: "MBps" | "Mbps";
/** Font size of the path bar and file list. */
textSize: "compact" | "default" | "large";
@@ -60,6 +74,8 @@ export interface AppSettings {
export const DEFAULT_SETTINGS: AppSettings = {
theme: "system",
accent: "blue",
iconColors: "multi",
speedUnit: "MBps",
textSize: "default",
transferMode: "single",
+52
View File
@@ -17,6 +17,7 @@ import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar';
import { ToastService } from './toast.service';
import { TransferBar } from './transfer-bar/transfer-bar';
import { TransfersService } from './transfers.service';
@Component({
selector: 'app-root',
@@ -43,6 +44,7 @@ export class App {
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService);
constructor() {
document.title = BRANDING.appName;
@@ -69,6 +71,10 @@ export class App {
/** Native application-menu actions forwarded from the bun process. */
private handleMenuAction(action: string): void {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) {
const id = action.slice('connect:'.length);
const connection = this.connections.connections().find((c) => c.id === id);
@@ -114,6 +120,52 @@ export class App {
} else if (event.ctrlKey && event.key === 'Tab') {
event.preventDefault();
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
} else if (mod && !event.shiftKey && !event.altKey) {
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like
// Firefox. Matched on event.code so the digit row works on any layout.
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
if (!digit) return;
event.preventDefault();
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1);
}
}
/** Context-menu actions apply to the row/panel remembered at right-click time. */
private handleContextAction(action: string): void {
const target = this.actions.menuTarget;
if (!target) return;
const { panel, side, entry } = target;
switch (action) {
case 'ctx-open':
if (entry) panel.open(entry);
break;
case 'ctx-edit':
void this.actions.edit(panel);
break;
case 'ctx-copy':
void this.transfers.copyToOtherPanel(side);
break;
case 'ctx-rename':
this.actions.rename(panel);
break;
case 'ctx-chmod':
this.actions.chmod(panel);
break;
case 'ctx-delete':
this.actions.delete(panel);
break;
case 'ctx-trash':
if (entry) void this.actions.trash(panel, entry);
break;
case 'ctx-new-folder':
this.actions.mkdir(panel);
break;
case 'ctx-new-file':
this.actions.createFile(panel);
break;
case 'ctx-refresh':
void panel.refresh();
break;
}
}
@@ -196,13 +196,22 @@
} @else {
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Private key file</span>
<div class="flex items-center gap-1">
<input
[value]="d.privateKeyPath"
(input)="patch('privateKeyPath', $any($event.target).value)"
placeholder="~/.ssh/id_ed25519"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
(click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent"
>
Choose…
</button>
</div>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Passphrase (optional)</span>
@@ -4,6 +4,7 @@ import type { SavedConnection } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { DialogsService } from '../dialogs.service';
import { PanelsService } from '../panels.service';
import { RpcService } from '../rpc.service';
interface Draft {
id: string;
@@ -64,6 +65,7 @@ export class ConnectionManager {
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService);
private readonly rpc = inject(RpcService);
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
@@ -149,6 +151,11 @@ export class ConnectionManager {
});
}
async pickKeyFile(): Promise<void> {
const res = await this.rpc.request.pickKeyFile({});
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
}
openFolder(connections: SavedConnection[]): void {
this.panels.openInNewTabs(connections);
this.close();
+30 -1
View File
@@ -2,11 +2,18 @@ import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types';
import { DialogsService } from './dialogs.service';
import { joinPath } from './format';
import { type PanelState } from './panels.service';
import { type PanelState, type Side } from './panels.service';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { ToastService } from './toast.service';
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
export interface MenuTarget {
side: Side;
panel: PanelState;
entry: FileEntry | null;
}
/**
* File operations invoked from a panel's toolbar / keyboard. They act on the
* GIVEN panel: on the selection if there is one, else on the cursor row.
@@ -18,6 +25,28 @@ export class FileActionsService {
private readonly dialogs = inject(DialogsService);
private readonly settings = inject(SettingsService);
menuTarget: MenuTarget | null = null;
/** Right-click entry point: remember the target and ask bun for a native menu. */
async openContextMenu(target: MenuTarget): Promise<void> {
this.menuTarget = target;
const { panel, entry } = target;
await this.rpc.request.showRowMenu({
path: entry?.path ?? panel.cwd(),
name: entry?.name ?? '',
type: entry?.type ?? 'dir',
isLocal: panel.sessionId() === 'local',
hasEntry: !!entry,
});
}
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
const res = await this.rpc.request.moveToTrash({ path: entry.path });
if (!res.ok) this.toast.error(res.error);
else this.toast.info(`Moved "${entry.name}" to Trash`);
await panel.refresh();
}
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
targets(panel: PanelState): FileEntry[] {
const selected = panel.selectedEntries();
+84
View File
@@ -0,0 +1,84 @@
// File-list glyphs + per-category colors ("Multi" icon mode). Category colors
// are fixed tokens (never follow the accent) so a folder is always folder-blue.
// Only the icon glyph is colored; row text stays neutral. Docs and unknown
// files stay muted on purpose — sparing color is what keeps it scannable.
import type { FileEntry } from '@shared/types';
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
// Class strings are literals so the Tailwind scanner picks them up.
const MUTED = 'shrink-0 text-muted-foreground';
const PRIMARY = 'shrink-0 text-primary';
const FOLDER = 'shrink-0 text-icon-folder';
const LINK = 'shrink-0 text-icon-link';
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = {
image: {
glyph: 'mynaImage',
class: 'shrink-0 text-icon-image',
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
},
audio: {
glyph: 'mynaMusic',
class: 'shrink-0 text-icon-media',
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
},
video: {
glyph: 'mynaVideo',
class: 'shrink-0 text-icon-media',
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
},
archive: {
glyph: 'mynaArchive',
class: 'shrink-0 text-icon-archive',
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
},
code: {
glyph: 'mynaCode',
class: 'shrink-0 text-icon-code',
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
extensions: (
'js mjs cjs ts tsx jsx json jsonc yaml yml toml xml html htm css scss sass less ' +
'py rb go rs java kt kts swift c h cpp hpp cc m mm cs php sh bash zsh fish ps1 bat ' +
'sql lua pl r vue svelte astro ini conf cfg env plist gradle cmake nix proto graphql ' +
'dart ex exs zig lock makefile dockerfile zshrc bashrc bash_profile profile ' +
'gitignore gitconfig gitattributes editorconfig npmrc nvmrc'
).split(' '),
},
doc: {
glyph: 'mynaFileText',
class: MUTED, // docs keep the neutral color; only the glyph changes
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
},
};
const EXT_CATEGORY = new Map<string, Category>();
for (const category of Object.keys(CATEGORIES) as Category[]) {
for (const ext of CATEGORIES[category].extensions) EXT_CATEGORY.set(ext, category);
}
/** "a.tar.gz" -> "gz"; ".zshrc" -> "zshrc"; "Makefile" (no dot) -> "makefile". */
function categoryOf(name: string): Category | null {
const dot = name.lastIndexOf('.');
const key = (dot >= 0 ? name.slice(dot + 1) : name).toLowerCase();
return EXT_CATEGORY.get(key) ?? null;
}
export function fileGlyph(entry: FileEntry, multi: boolean): string {
if (entry.name === '..') return 'mynaArrowUp';
if (entry.type === 'dir') return 'mynaFolder';
if (!multi) return 'mynaFile';
if (entry.type === 'symlink') return 'mynaLink';
const category = categoryOf(entry.name);
return category ? CATEGORIES[category].glyph : 'mynaFile';
}
export function fileIconClass(entry: FileEntry, multi: boolean): string {
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
if (entry.name === '..') return MUTED;
if (entry.type === 'dir') return FOLDER;
if (entry.type === 'symlink') return LINK;
const category = categoryOf(entry.name);
return category ? CATEGORIES[category].class : MUTED;
}
+25 -6
View File
@@ -186,12 +186,31 @@
</button>
</div>
<!-- type-to-filter bar (appears while a query is active) -->
@if (panel().filter(); as query) {
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[11px] text-muted-foreground">
{{ filterMatches() }} match{{ filterMatches() === 1 ? '' : 'es' }} · Esc
</span>
<button
(click)="clearFilter()"
title="Clear filter (Esc)"
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
<!-- file list -->
<div
#list
tabindex="0"
(keydown)="onKeydown($event)"
(mousedown)="activate()"
(contextmenu)="listContextMenu($event)"
class="flex-1 overflow-y-auto outline-none"
>
@for (entry of panel().sorted(); track entry.name; let i = $index) {
@@ -199,19 +218,16 @@
[attr.data-index]="i"
(click)="rowClick($event, i)"
(dblclick)="openAt(i)"
(contextmenu)="rowContextMenu($event, i)"
class="grid h-(--row-h) grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] items-center text-(length:--list-font)"
[class.bg-accent]="i === panel().cursor() && !isSelected(entry)"
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
[class.bg-primary/15]="isSelected(entry)"
[class.ring-1]="i === panel().cursor() && isActive()"
[class.ring-inset]="i === panel().cursor() && isActive()"
[class.ring-ring]="i === panel().cursor() && isActive()"
>
<div class="flex min-w-0 items-center gap-1.5 px-2">
<ng-icon
[name]="iconFor(entry)"
size="0.95rem"
[class]="entry.type === 'dir' ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
/>
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</div>
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
@@ -224,4 +240,7 @@
} @empty {
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
}
@if (panel().filter() && filterMatches() === 0) {
<div class="p-6 text-center text-xs text-muted-foreground">No matches</div>
}
</div>
+62 -2
View File
@@ -13,8 +13,10 @@ import type { FileEntry, SavedConnection } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { DialogsService } from '../dialogs.service';
import { FileActionsService } from '../file-actions.service';
import { fileGlyph, fileIconClass } from '../file-icons';
import { formatDate, formatSize } from '../format';
import { PanelsService, type Side, type SortKey } from '../panels.service';
import { SettingsService } from '../settings.service';
import { TransfersService } from '../transfers.service';
@Component({
@@ -31,6 +33,7 @@ export class FilePanel {
readonly transfers = inject(TransfersService);
readonly connections = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
private readonly settings = inject(SettingsService);
readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side());
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
@@ -92,14 +95,28 @@ export class FilePanel {
}
iconFor(entry: FileEntry): string {
if (entry.name === '..') return 'mynaArrowUp';
return entry.type === 'dir' ? 'mynaFolder' : 'mynaFile';
return fileGlyph(entry, this.settings.iconColors() === 'multi');
}
iconClass(entry: FileEntry): string {
return fileIconClass(entry, this.settings.iconColors() === 'multi');
}
isSelected(entry: FileEntry): boolean {
return this.panel().selected().has(entry.name);
}
clearFilter(): void {
this.panel().setFilter('');
this.listEl().nativeElement.focus();
}
filterMatches(): number {
return this.panel()
.sorted()
.filter((e) => e.name !== '..').length;
}
sortIndicator(key: SortKey): string {
if (this.panel().sortKey() !== key) return '';
return this.panel().sortAsc() ? '↑' : '↓';
@@ -131,6 +148,24 @@ export class FilePanel {
this.lastClickedIndex = index;
}
rowContextMenu(event: MouseEvent, index: number): void {
event.preventDefault();
event.stopPropagation();
this.activate();
const panel = this.panel();
const entry = panel.sorted()[index] ?? null;
// standard behavior: keep the selection when right-clicking inside it
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
panel.cursor.set(index);
void this.actions.openContextMenu({ side: this.side(), panel, entry });
}
listContextMenu(event: MouseEvent): void {
event.preventDefault();
this.activate();
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null });
}
openAt(index: number): void {
const entry = this.panel().sorted()[index];
if (entry) this.panel().open(entry);
@@ -151,6 +186,31 @@ export class FilePanel {
return;
}
// type-to-filter: printable characters build the query (Space only once a
// query exists — an empty-filter Space keeps its toggle-select meaning)
if (
key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
(key !== ' ' || panel.filter() !== '')
) {
event.preventDefault();
panel.setFilter(panel.filter() + key);
this.scrollCursorIntoView();
return;
}
if (key === 'Backspace' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter(panel.filter().slice(0, -1));
return;
}
if (key === 'Escape' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter('');
return;
}
switch (key) {
case 'ArrowDown':
panel.moveCursor(1);
+14
View File
@@ -2,6 +2,7 @@
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline
// (or /solid, where names get a `Solid` suffix).
import {
mynaArchive,
mynaArrowLeft,
mynaArrowLeftRight,
mynaArrowRight,
@@ -10,6 +11,7 @@ import {
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaCode,
mynaCog,
mynaConfigVertical,
mynaEdit,
@@ -19,18 +21,24 @@ import {
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
mynaImage,
mynaLink,
mynaLock,
mynaLogout,
mynaMusic,
mynaPlus,
mynaRefresh,
mynaSearch,
mynaServers,
mynaSpinner,
mynaTrash,
mynaVideo,
mynaX,
mynaXCircle,
} from "@ng-icons/mynaui/outline";
export const APP_ICONS = {
mynaArchive,
mynaArrowLeft,
mynaArrowLeftRight,
mynaArrowRight,
@@ -39,6 +47,7 @@ export const APP_ICONS = {
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaCode,
mynaCog,
mynaConfigVertical,
mynaEdit,
@@ -48,13 +57,18 @@ export const APP_ICONS = {
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
mynaImage,
mynaLink,
mynaLock,
mynaLogout,
mynaMusic,
mynaPlus,
mynaRefresh,
mynaSearch,
mynaServers,
mynaSpinner,
mynaTrash,
mynaVideo,
mynaX,
mynaXCircle,
};
+21 -1
View File
@@ -24,10 +24,16 @@ export class PanelState {
readonly sortAsc = signal(true);
readonly cursor = signal(0);
readonly selected = signal<ReadonlySet<string>>(new Set());
/** Type-to-filter query; entries not matching are hidden (".." always stays). */
readonly filter = signal('');
/** Directories first, then the active sort column. ".." (parent) is pinned on top. */
readonly sorted = computed(() => {
const list = [...this.entries()];
const query = this.filter().toLowerCase();
let list = [...this.entries()];
if (query) {
list = list.filter((e) => e.name.toLowerCase().includes(query));
}
const key = this.sortKey();
const dir = this.sortAsc() ? 1 : -1;
list.sort((a, b) => {
@@ -69,6 +75,7 @@ export class PanelState {
this.entries.set(res.entries);
this.cursor.set(0);
this.selected.set(new Set());
this.filter.set('');
} finally {
this.loading.set(false);
}
@@ -110,6 +117,13 @@ export class PanelState {
void this.load(`${this.cwd()}/..`);
}
/** Update the filter and park the cursor on the first real match. */
setFilter(query: string): void {
this.filter.set(query);
const first = this.sorted().findIndex((e) => e.name !== '..');
this.cursor.set(first >= 0 ? first : 0);
}
toggleSort(key: SortKey): void {
if (this.sortKey() === key) {
this.sortAsc.update((v) => !v);
@@ -312,6 +326,12 @@ export class PanelsService {
this.setActiveWorkspace(next.id);
}
/** Jump to a tab by visual position; negative counts from the end (⌘9 = last). */
goToWorkspace(index: number): void {
const target = this.workspaces().at(index);
if (target) this.setActiveWorkspace(target.id);
}
/** Tab label: "Local" for a local/local pair, else the remote titles. */
workspaceName(workspace: Workspace): string {
const remoteTitles = [workspace.left, workspace.right]
+3 -2
View File
@@ -1,11 +1,12 @@
// Injected by the bun process via BrowserWindow preload before any page script.
// Brand-neutral name so rebrands never touch this file.
declare global {
interface Window {
__BUNSCP_PLATFORM?: string;
__APP_PLATFORM?: string;
}
}
export const PLATFORM = typeof window !== 'undefined' ? (window.__BUNSCP_PLATFORM ?? '') : '';
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
/** True when the window uses macOS hiddenInset chrome (tab bar fused with titlebar). */
export const IS_MAC_CHROME = PLATFORM === 'darwin';
+30
View File
@@ -5,6 +5,8 @@ import { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' })
export class SettingsService {
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
@@ -16,10 +18,14 @@ export class SettingsService {
private readonly rpc = inject(RpcService);
constructor() {
// the default accent is not the CSS default — apply before first paint
this.applyAccent();
if (!this.rpc.available) return;
void this.rpc.request.getSettings({}).then((res) => {
if (!res.ok) return;
this.theme.set(res.settings.theme);
this.accent.set(res.settings.accent);
this.iconColors.set(res.settings.iconColors);
this.speedUnit.set(res.settings.speedUnit);
this.textSize.set(res.settings.textSize);
this.transferMode.set(res.settings.transferMode);
@@ -28,6 +34,7 @@ export class SettingsService {
this.editor.set(res.settings.editor);
this.editorApp.set(res.settings.editorApp);
this.applyTheme();
this.applyAccent();
this.applyTextSize();
});
}
@@ -38,6 +45,18 @@ export class SettingsService {
void this.persist();
}
setAccent(tone: AppSettings['accent']): void {
this.accent.set(tone);
this.applyAccent();
void this.persist();
}
setIconColors(mode: AppSettings['iconColors']): void {
// no apply step — the file panels read the signal reactively
this.iconColors.set(mode);
void this.persist();
}
setSpeedUnit(unit: AppSettings['speedUnit']): void {
this.speedUnit.set(unit);
void this.persist();
@@ -83,6 +102,15 @@ export class SettingsService {
}
}
private applyAccent(): void {
const tone = this.accent();
if (tone === 'graphite') {
delete document.documentElement.dataset['accent'];
} else {
document.documentElement.dataset['accent'] = tone;
}
}
private applyTextSize(): void {
const size = this.textSize();
if (size === 'default') {
@@ -95,6 +123,8 @@ export class SettingsService {
protected settings(): AppSettings {
return {
theme: this.theme(),
accent: this.accent(),
iconColors: this.iconColors(),
speedUnit: this.speedUnit(),
textSize: this.textSize(),
transferMode: this.transferMode(),
+51
View File
@@ -1,16 +1,30 @@
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { AccentTone } from '@shared/types';
import { PanelsService } from '../panels.service';
import { IS_MAC_CHROME } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
const THEMES = ['system', 'light', 'dark'] 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).
const ACCENTS: readonly { value: AccentTone; swatch: string; label: string }[] = [
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)', label: 'Graphite' },
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)', label: 'Blue' },
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)', label: 'Indigo' },
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)', label: 'Purple' },
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)', label: 'Rose' },
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)', label: 'Orange' },
{ value: 'green', swatch: 'oklch(0.65 0.15 150)', label: 'Green' },
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)', label: 'Teal' },
];
const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' },
] as const;
const TEXT_SIZES = ['compact', 'default', 'large'] as const;
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@@ -119,6 +133,41 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Accent</span>
<div class="flex items-center gap-1.5">
@for (tone of accents; track tone.value) {
<button
(click)="settings.setAccent(tone.value)"
[title]="tone.label"
class="size-3.5 rounded-full border border-foreground/15"
[style.background-color]="tone.swatch"
[class.ring-2]="settings.accent() === tone.value"
[class.ring-foreground]="settings.accent() === tone.value"
[class.ring-offset-1]="settings.accent() === tone.value"
[class.ring-offset-card]="settings.accent() === tone.value"
></button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">File colors</span>
<div class="flex rounded-md border border-border p-0.5">
@for (mode of iconColorModes; track mode) {
<button
(click)="settings.setIconColors(mode)"
class="rounded px-2 py-0.5 text-[11px] capitalize"
[class.bg-accent]="settings.iconColors() === mode"
[class.text-foreground]="settings.iconColors() === mode"
[class.text-muted-foreground]="settings.iconColors() !== mode"
>
{{ mode }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Text size</span>
<div class="flex rounded-md border border-border p-0.5">
@@ -273,6 +322,8 @@ export class TabBar {
readonly settingsOpen = signal(false);
readonly isMacChrome = IS_MAC_CHROME;
readonly themes = THEMES;
readonly accents = ACCENTS;
readonly iconColorModes = ICON_COLOR_MODES;
readonly speedUnits = SPEED_UNITS;
readonly textSizes = TEXT_SIZES;
readonly transferModes = TRANSFER_MODES;
+63
View File
@@ -22,6 +22,13 @@
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--radius: 0.5rem;
/* file-type icon colors ("Multi" mode) — fixed category hues, muted chroma */
--icon-folder: oklch(0.58 0.13 250);
--icon-link: oklch(0.56 0.1 190);
--icon-image: oklch(0.58 0.13 295);
--icon-media: oklch(0.6 0.13 10);
--icon-archive: oklch(0.62 0.13 60);
--icon-code: oklch(0.56 0.12 155);
}
/* Dark tokens apply when the user forces dark, or follows a dark system theme.
@@ -45,6 +52,12 @@
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--icon-folder: oklch(0.75 0.11 250);
--icon-link: oklch(0.74 0.09 190);
--icon-image: oklch(0.76 0.11 295);
--icon-media: oklch(0.76 0.11 10);
--icon-archive: oklch(0.77 0.11 65);
--icon-code: oklch(0.75 0.1 155);
}
@media (prefers-color-scheme: dark) {
@@ -67,6 +80,12 @@
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--icon-folder: oklch(0.75 0.11 250);
--icon-link: oklch(0.74 0.09 190);
--icon-image: oklch(0.76 0.11 295);
--icon-media: oklch(0.76 0.11 10);
--icon-archive: oklch(0.77 0.11 65);
--icon-code: oklch(0.75 0.1 155);
}
}
@@ -95,6 +114,13 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-cursor-tint: var(--cursor-tint);
--color-icon-folder: var(--icon-folder);
--color-icon-link: var(--icon-link);
--color-icon-image: var(--icon-image);
--color-icon-media: var(--icon-media);
--color-icon-archive: var(--icon-archive);
--color-icon-code: var(--icon-code);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -125,6 +151,43 @@
--path-ctl-h: 32px;
}
/* Accent tone (data-accent set by SettingsService; absent = graphite/zinc).
Each tone is one hue+chroma line; the formula blocks below derive --primary,
--ring and the cursor-row tint for light and both dark states. The dark
formula blocks (0,3,0) outrank the base dark token blocks and the light
formula block (0,2,0) in every dark state; graphite matches no [data-accent]
selector and keeps the zinc tokens untouched. */
:root {
/* graphite: cursor row identical to the old bg-accent highlight */
--cursor-tint: var(--accent);
}
:root[data-accent='blue'] { --tone-h: 255; --tone-c: 0.17; }
:root[data-accent='indigo'] { --tone-h: 277; --tone-c: 0.19; }
:root[data-accent='purple'] { --tone-h: 305; --tone-c: 0.17; }
:root[data-accent='rose'] { --tone-h: 8; --tone-c: 0.17; }
/* darkest orange that still reads orange; white button text lands ≈ 4.3:1 */
:root[data-accent='orange'] { --tone-h: 50; --tone-c: 0.18; --tone-l: 0.58; }
:root[data-accent='green'] { --tone-h: 150; --tone-c: 0.15; }
:root[data-accent='teal'] { --tone-h: 185; --tone-c: 0.12; }
:root[data-accent] {
--primary: oklch(var(--tone-l, 0.55) var(--tone-c) var(--tone-h));
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
--cursor-tint: color-mix(in oklab, var(--primary) 8%, transparent);
}
:root[data-theme='dark'][data-accent] {
--primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h));
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
}
@media (prefers-color-scheme: dark) {
:root[data-accent]:not([data-theme='light']) {
--primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h));
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
}
}
html,
body {
height: 100%;