Compare commits
10
Commits
3fb08fdc24
...
ba0b2434fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0b2434fb | ||
|
|
320080de6d | ||
|
|
44f077380f | ||
|
|
2f4bc63ac8 | ||
|
|
c7b0418904 | ||
|
|
aed565e424 | ||
|
|
a0fc96fc44 | ||
|
|
fbbde16f35 | ||
|
|
9737cad6de | ||
|
|
41f9e14cad |
@@ -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 |
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "bunscp",
|
||||
"name": "miniscp",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user