Compare commits
10
Commits
| 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
|
A cross-platform, dual-panel SFTP/local file manager — a small WinSCP-style
|
||||||
commander built on **Bun**, **Electrobun**, and **Angular**.
|
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
|
## Build a packaged app
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
```ts
|
||||||
export const BRANDING = {
|
export const BRANDING = {
|
||||||
appName: "BunSCP", // window title, bundle name, app-data folder
|
appName: "miniSCP", // window title, bundle name, app-data folder
|
||||||
identifier: "es.teknes.bunscp",
|
identifier: "es.teknes.miniscp",
|
||||||
|
legacyNames: ["BunSCP"], // previous names; their data dirs migrate on boot
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
icon: "assets/icon.iconset",
|
icon: "assets/icon.iconset",
|
||||||
} as const;
|
} 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
|
tray template image, and the tray progress frames from the SVGs
|
||||||
(`@resvg/resvg-js`, dev-only dependency).
|
(`@resvg/resvg-js`, dev-only dependency).
|
||||||
|
|
||||||
> Renaming `appName` changes the app-data directory, so existing saved
|
> Renaming `appName` changes the app-data directory. Add the old name to
|
||||||
> connections won't be found under the new name — copy `connections.json` across
|
> `legacyNames` and the app moves the previous folder (connections, settings,
|
||||||
> if you need them.
|
> edit temp files) into place on the next boot.
|
||||||
|
|
||||||
## Where connections are stored
|
## Where connections are stored
|
||||||
|
|
||||||
Saved connections live in a plain JSON file in the OS app-data directory
|
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.
|
`0600` permissions.
|
||||||
|
|
||||||
> ⚠️ **Passwords are stored in plaintext** in this file (a deliberate v1
|
> ⚠️ **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
|
## Same-file protection
|
||||||
|
|
||||||
Copying a file or folder onto itself would truncate it to zero bytes (the
|
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:
|
this at several layers:
|
||||||
|
|
||||||
- **Same session / same host**: every connection carries a filesystem identity
|
- **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">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
<!-- BunSCP tray glyph: black + alpha only (macOS template image).
|
<!-- BunSCP tray glyph: black + alpha only (macOS template image).
|
||||||
Same geometry as logo.svg, slightly bolder for menu-bar sizes. -->
|
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">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M8.2 8.2 4.6 4.6m0 0v4.2m0-4.2h4.2"/>
|
<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="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"/>
|
<!-- lock: outlined so the shackle + body silhouette stays readable small -->
|
||||||
<rect x="9.4" y="11.2" width="5.2" height="4.6" rx="1.1" fill="#000" stroke="#000"/>
|
<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>
|
</g>
|
||||||
</svg>
|
</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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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 { BRANDING } from "../shared/branding";
|
||||||
import { effectiveConcurrency } from "../shared/types";
|
import { effectiveConcurrency } from "../shared/types";
|
||||||
|
import { PanelContextMenu } from "./context-menu";
|
||||||
import { EditManager } from "./edits";
|
import { EditManager } from "./edits";
|
||||||
import { SessionRegistry } from "./fs/registry";
|
import { SessionRegistry } from "./fs/registry";
|
||||||
import { applyApplicationMenu } from "./menu";
|
import { applyApplicationMenu } from "./menu";
|
||||||
import { createAppRPC } from "./rpc-handlers";
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
import { ConnectionStore } from "./store/connections";
|
import { ConnectionStore } from "./store/connections";
|
||||||
|
import { migrateLegacyDataDir } from "./store/paths";
|
||||||
import { SettingsStore } from "./store/settings";
|
import { SettingsStore } from "./store/settings";
|
||||||
import { AppTray } from "./tray";
|
import { AppTray } from "./tray";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
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 registry = new SessionRegistry();
|
||||||
const engine = new TransferEngine(registry);
|
const engine = new TransferEngine(registry);
|
||||||
const store = new ConnectionStore();
|
const store = new ConnectionStore();
|
||||||
const settings = new SettingsStore();
|
const settings = new SettingsStore();
|
||||||
const edits = new EditManager(registry, engine, settings);
|
const edits = new EditManager(registry, engine, settings);
|
||||||
|
const contextMenu = new PanelContextMenu();
|
||||||
const events = {
|
const events = {
|
||||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||||
connectionsChanged: () => {},
|
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 url = await getMainViewUrl();
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
@@ -60,13 +66,64 @@ const win = new BrowserWindow({
|
|||||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||||
: {}),
|
: {}),
|
||||||
// lets the UI pick the fused layout before first paint
|
// 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,
|
rpc,
|
||||||
});
|
});
|
||||||
|
|
||||||
new AppTray(win);
|
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.sessionClosed = (info) => rpc.send.sessionClosed(info);
|
||||||
events.connectionsChanged = () => void applyApplicationMenu(store);
|
events.connectionsChanged = () => void applyApplicationMenu(store);
|
||||||
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
|
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
|
||||||
|
|||||||
+38
-1
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserView, Utils } from "electrobun/bun";
|
import { BrowserView, Utils } from "electrobun/bun";
|
||||||
import type { AppRPC } from "../shared/rpc";
|
import type { AppRPC } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type Result } from "../shared/types";
|
import { effectiveConcurrency, type Result } from "../shared/types";
|
||||||
|
import type { PanelContextMenu } from "./context-menu";
|
||||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
import { SftpProvider } from "./fs/sftp";
|
import { SftpProvider } from "./fs/sftp";
|
||||||
@@ -19,6 +20,7 @@ export interface AppDeps {
|
|||||||
store: ConnectionStore;
|
store: ConnectionStore;
|
||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
edits: EditManager;
|
edits: EditManager;
|
||||||
|
contextMenu: PanelContextMenu;
|
||||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||||
events: {
|
events: {
|
||||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
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>({
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
maxRequestTime: MAX_REQUEST_TIME_MS,
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
handlers: {
|
handlers: {
|
||||||
@@ -193,6 +203,20 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
|
|||||||
return { path };
|
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: () =>
|
getEditLeftovers: () =>
|
||||||
wrap(async () => ({ names: await edits.listLeftovers() })),
|
wrap(async () => ({ names: await edits.listLeftovers() })),
|
||||||
|
|
||||||
@@ -201,6 +225,19 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
|
|||||||
await edits.clearLeftovers();
|
await edits.clearLeftovers();
|
||||||
return {};
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { BRANDING } from "../../shared/branding";
|
import { BRANDING } from "../../shared/branding";
|
||||||
|
|
||||||
export function appDataDir(): string {
|
function dirFor(appName: string): string {
|
||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
switch (process.platform) {
|
switch (process.platform) {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return path.join(home, "Library", "Application Support", BRANDING.appName);
|
return path.join(home, "Library", "Application Support", appName);
|
||||||
case "win32":
|
case "win32":
|
||||||
return path.join(
|
return path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), appName);
|
||||||
process.env.APPDATA ?? path.join(home, "AppData", "Roaming"),
|
|
||||||
BRANDING.appName,
|
|
||||||
);
|
|
||||||
default:
|
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 { pipeline } from "node:stream/promises";
|
||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
|
import { BRANDING } from "../../shared/branding";
|
||||||
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
|
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
|
||||||
import type { FileSystemProvider } from "../fs/provider";
|
import type { FileSystemProvider } from "../fs/provider";
|
||||||
import type { SessionRegistry } from "../fs/registry";
|
import type { SessionRegistry } from "../fs/registry";
|
||||||
import { walkTree } from "./walk";
|
import { walkTree } from "./walk";
|
||||||
|
|
||||||
const PROGRESS_INTERVAL_MS = 100;
|
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
|
// workers per job; the shared slot budget is the real limit
|
||||||
const MAX_WORKERS_PER_JOB = 8;
|
const MAX_WORKERS_PER_JOB = 8;
|
||||||
|
|
||||||
@@ -146,6 +149,22 @@ export class TransferEngine {
|
|||||||
if (job && TERMINAL.includes(job.snapshot.state)) this.jobs.delete(jobId);
|
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. */
|
/** Fail every non-terminal job that touches a dead session. */
|
||||||
failSession(sessionId: string, reason: string): void {
|
failSession(sessionId: string, reason: string): void {
|
||||||
for (const job of this.jobs.values()) {
|
for (const job of this.jobs.values()) {
|
||||||
@@ -343,7 +362,8 @@ export class TransferEngine {
|
|||||||
dir: string,
|
dir: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const token = crypto.randomUUID();
|
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 {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const stream = dst.createWriteStream(probePath);
|
const stream = dst.createWriteStream(probePath);
|
||||||
@@ -352,7 +372,7 @@ export class TransferEngine {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const chunks: Buffer[] = [];
|
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);
|
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||||
}
|
}
|
||||||
const seen = Buffer.concat(chunks).toString("utf8") === token;
|
const seen = Buffer.concat(chunks).toString("utf8") === token;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// The single place to rebrand the app. Everything (window title, bundle name,
|
// The single place to rebrand the app. Everything (window title, bundle name,
|
||||||
// app-data directory, UI header, icon) reads from here.
|
// app-data directory, UI header, icon) reads from here.
|
||||||
export const BRANDING = {
|
export const BRANDING = {
|
||||||
appName: "BunSCP",
|
appName: "miniSCP",
|
||||||
identifier: "es.teknes.bunscp",
|
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",
|
version: "0.1.0",
|
||||||
// macOS build converts this .iconset folder into AppIcon.icns.
|
// macOS build converts this .iconset folder into AppIcon.icns.
|
||||||
// Regenerate from assets/icon.png with scripts/gen-iconset.sh.
|
// Regenerate from assets/icon.png with scripts/gen-iconset.sh.
|
||||||
|
|||||||
@@ -91,11 +91,27 @@ export type AppRPC = {
|
|||||||
params: Record<string, never>;
|
params: Record<string, never>;
|
||||||
response: Result<{ path: string | null }>;
|
response: Result<{ path: string | null }>;
|
||||||
};
|
};
|
||||||
|
pickKeyFile: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
response: Result<{ path: string | null }>;
|
||||||
|
};
|
||||||
getEditLeftovers: {
|
getEditLeftovers: {
|
||||||
params: Record<string, never>;
|
params: Record<string, never>;
|
||||||
response: Result<{ names: string[] }>;
|
response: Result<{ names: string[] }>;
|
||||||
};
|
};
|
||||||
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
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>;
|
messages: Record<never, never>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,8 +41,22 @@ export interface ConnectionsFile {
|
|||||||
connections: SavedConnection[];
|
connections: SavedConnection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AccentTone =
|
||||||
|
| "graphite"
|
||||||
|
| "blue"
|
||||||
|
| "indigo"
|
||||||
|
| "purple"
|
||||||
|
| "rose"
|
||||||
|
| "orange"
|
||||||
|
| "green"
|
||||||
|
| "teal";
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
theme: "system" | "light" | "dark";
|
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";
|
speedUnit: "MBps" | "Mbps";
|
||||||
/** Font size of the path bar and file list. */
|
/** Font size of the path bar and file list. */
|
||||||
textSize: "compact" | "default" | "large";
|
textSize: "compact" | "default" | "large";
|
||||||
@@ -60,6 +74,8 @@ export interface AppSettings {
|
|||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
|
accent: "blue",
|
||||||
|
iconColors: "multi",
|
||||||
speedUnit: "MBps",
|
speedUnit: "MBps",
|
||||||
textSize: "default",
|
textSize: "default",
|
||||||
transferMode: "single",
|
transferMode: "single",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { StatusBar } from './status-bar/status-bar';
|
|||||||
import { TabBar } from './tab-bar/tab-bar';
|
import { TabBar } from './tab-bar/tab-bar';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||||
|
import { TransfersService } from './transfers.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -43,6 +44,7 @@ export class App {
|
|||||||
readonly panels = inject(PanelsService);
|
readonly panels = inject(PanelsService);
|
||||||
readonly actions = inject(FileActionsService);
|
readonly actions = inject(FileActionsService);
|
||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
|
private readonly transfers = inject(TransfersService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
document.title = BRANDING.appName;
|
document.title = BRANDING.appName;
|
||||||
@@ -69,6 +71,10 @@ export class App {
|
|||||||
|
|
||||||
/** Native application-menu actions forwarded from the bun process. */
|
/** Native application-menu actions forwarded from the bun process. */
|
||||||
private handleMenuAction(action: string): void {
|
private handleMenuAction(action: string): void {
|
||||||
|
if (action.startsWith('ctx-')) {
|
||||||
|
this.handleContextAction(action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action.startsWith('connect:')) {
|
if (action.startsWith('connect:')) {
|
||||||
const id = action.slice('connect:'.length);
|
const id = action.slice('connect:'.length);
|
||||||
const connection = this.connections.connections().find((c) => c.id === id);
|
const connection = this.connections.connections().find((c) => c.id === id);
|
||||||
@@ -114,6 +120,52 @@ export class App {
|
|||||||
} else if (event.ctrlKey && event.key === 'Tab') {
|
} else if (event.ctrlKey && event.key === 'Tab') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
|
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 {
|
} @else {
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
<span class="text-muted-foreground">Private key file</span>
|
<span class="text-muted-foreground">Private key file</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
[value]="d.privateKeyPath"
|
[value]="d.privateKeyPath"
|
||||||
(input)="patch('privateKeyPath', $any($event.target).value)"
|
(input)="patch('privateKeyPath', $any($event.target).value)"
|
||||||
placeholder="~/.ssh/id_ed25519"
|
placeholder="~/.ssh/id_ed25519"
|
||||||
spellcheck="false"
|
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>
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
<span class="text-muted-foreground">Passphrase (optional)</span>
|
<span class="text-muted-foreground">Passphrase (optional)</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { SavedConnection } from '@shared/types';
|
|||||||
import { ConnectionsService } from '../connections.service';
|
import { ConnectionsService } from '../connections.service';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { DialogsService } from '../dialogs.service';
|
||||||
import { PanelsService } from '../panels.service';
|
import { PanelsService } from '../panels.service';
|
||||||
|
import { RpcService } from '../rpc.service';
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,6 +65,7 @@ export class ConnectionManager {
|
|||||||
readonly store = inject(ConnectionsService);
|
readonly store = inject(ConnectionsService);
|
||||||
readonly dialogs = inject(DialogsService);
|
readonly dialogs = inject(DialogsService);
|
||||||
readonly panels = inject(PanelsService);
|
readonly panels = inject(PanelsService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
|
||||||
readonly draft = signal<Draft | null>(null);
|
readonly draft = signal<Draft | null>(null);
|
||||||
readonly busy = signal(false);
|
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 {
|
openFolder(connections: SavedConnection[]): void {
|
||||||
this.panels.openInNewTabs(connections);
|
this.panels.openInNewTabs(connections);
|
||||||
this.close();
|
this.close();
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import type { FileEntry } from '@shared/types';
|
import type { FileEntry } from '@shared/types';
|
||||||
import { DialogsService } from './dialogs.service';
|
import { DialogsService } from './dialogs.service';
|
||||||
import { joinPath } from './format';
|
import { joinPath } from './format';
|
||||||
import { type PanelState } from './panels.service';
|
import { type PanelState, type Side } from './panels.service';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
import { ToastService } from './toast.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
|
* 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.
|
* 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 dialogs = inject(DialogsService);
|
||||||
private readonly settings = inject(SettingsService);
|
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. */
|
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
|
||||||
targets(panel: PanelState): FileEntry[] {
|
targets(panel: PanelState): FileEntry[] {
|
||||||
const selected = panel.selectedEntries();
|
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>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- file list -->
|
||||||
<div
|
<div
|
||||||
#list
|
#list
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
(keydown)="onKeydown($event)"
|
(keydown)="onKeydown($event)"
|
||||||
(mousedown)="activate()"
|
(mousedown)="activate()"
|
||||||
|
(contextmenu)="listContextMenu($event)"
|
||||||
class="flex-1 overflow-y-auto outline-none"
|
class="flex-1 overflow-y-auto outline-none"
|
||||||
>
|
>
|
||||||
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
||||||
@@ -199,19 +218,16 @@
|
|||||||
[attr.data-index]="i"
|
[attr.data-index]="i"
|
||||||
(click)="rowClick($event, i)"
|
(click)="rowClick($event, i)"
|
||||||
(dblclick)="openAt(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="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.bg-primary/15]="isSelected(entry)"
|
||||||
[class.ring-1]="i === panel().cursor() && isActive()"
|
[class.ring-1]="i === panel().cursor() && isActive()"
|
||||||
[class.ring-inset]="i === panel().cursor() && isActive()"
|
[class.ring-inset]="i === panel().cursor() && isActive()"
|
||||||
[class.ring-ring]="i === panel().cursor() && isActive()"
|
[class.ring-ring]="i === panel().cursor() && isActive()"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-1.5 px-2">
|
<div class="flex min-w-0 items-center gap-1.5 px-2">
|
||||||
<ng-icon
|
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
|
||||||
[name]="iconFor(entry)"
|
|
||||||
size="0.95rem"
|
|
||||||
[class]="entry.type === 'dir' ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
|
||||||
/>
|
|
||||||
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
|
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
|
||||||
@@ -224,4 +240,7 @@
|
|||||||
} @empty {
|
} @empty {
|
||||||
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import type { FileEntry, SavedConnection } from '@shared/types';
|
|||||||
import { ConnectionsService } from '../connections.service';
|
import { ConnectionsService } from '../connections.service';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { DialogsService } from '../dialogs.service';
|
||||||
import { FileActionsService } from '../file-actions.service';
|
import { FileActionsService } from '../file-actions.service';
|
||||||
|
import { fileGlyph, fileIconClass } from '../file-icons';
|
||||||
import { formatDate, formatSize } from '../format';
|
import { formatDate, formatSize } from '../format';
|
||||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||||
|
import { SettingsService } from '../settings.service';
|
||||||
import { TransfersService } from '../transfers.service';
|
import { TransfersService } from '../transfers.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -31,6 +33,7 @@ export class FilePanel {
|
|||||||
readonly transfers = inject(TransfersService);
|
readonly transfers = inject(TransfersService);
|
||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
readonly dialogs = inject(DialogsService);
|
readonly dialogs = inject(DialogsService);
|
||||||
|
private readonly settings = inject(SettingsService);
|
||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
readonly panel = computed(() => this.panels.get(this.side()));
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||||
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
||||||
@@ -92,14 +95,28 @@ export class FilePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
iconFor(entry: FileEntry): string {
|
iconFor(entry: FileEntry): string {
|
||||||
if (entry.name === '..') return 'mynaArrowUp';
|
return fileGlyph(entry, this.settings.iconColors() === 'multi');
|
||||||
return entry.type === 'dir' ? 'mynaFolder' : 'mynaFile';
|
}
|
||||||
|
|
||||||
|
iconClass(entry: FileEntry): string {
|
||||||
|
return fileIconClass(entry, this.settings.iconColors() === 'multi');
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelected(entry: FileEntry): boolean {
|
isSelected(entry: FileEntry): boolean {
|
||||||
return this.panel().selected().has(entry.name);
|
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 {
|
sortIndicator(key: SortKey): string {
|
||||||
if (this.panel().sortKey() !== key) return '';
|
if (this.panel().sortKey() !== key) return '';
|
||||||
return this.panel().sortAsc() ? '↑' : '↓';
|
return this.panel().sortAsc() ? '↑' : '↓';
|
||||||
@@ -131,6 +148,24 @@ export class FilePanel {
|
|||||||
this.lastClickedIndex = index;
|
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 {
|
openAt(index: number): void {
|
||||||
const entry = this.panel().sorted()[index];
|
const entry = this.panel().sorted()[index];
|
||||||
if (entry) this.panel().open(entry);
|
if (entry) this.panel().open(entry);
|
||||||
@@ -151,6 +186,31 @@ export class FilePanel {
|
|||||||
return;
|
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) {
|
switch (key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
panel.moveCursor(1);
|
panel.moveCursor(1);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline
|
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline
|
||||||
// (or /solid, where names get a `Solid` suffix).
|
// (or /solid, where names get a `Solid` suffix).
|
||||||
import {
|
import {
|
||||||
|
mynaArchive,
|
||||||
mynaArrowLeft,
|
mynaArrowLeft,
|
||||||
mynaArrowLeftRight,
|
mynaArrowLeftRight,
|
||||||
mynaArrowRight,
|
mynaArrowRight,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
mynaCheckCircle,
|
mynaCheckCircle,
|
||||||
mynaChevronDown,
|
mynaChevronDown,
|
||||||
mynaChevronRight,
|
mynaChevronRight,
|
||||||
|
mynaCode,
|
||||||
mynaCog,
|
mynaCog,
|
||||||
mynaConfigVertical,
|
mynaConfigVertical,
|
||||||
mynaEdit,
|
mynaEdit,
|
||||||
@@ -19,18 +21,24 @@ import {
|
|||||||
mynaFolder,
|
mynaFolder,
|
||||||
mynaFolderPlus,
|
mynaFolderPlus,
|
||||||
mynaHardDrive,
|
mynaHardDrive,
|
||||||
|
mynaImage,
|
||||||
|
mynaLink,
|
||||||
mynaLock,
|
mynaLock,
|
||||||
mynaLogout,
|
mynaLogout,
|
||||||
|
mynaMusic,
|
||||||
mynaPlus,
|
mynaPlus,
|
||||||
mynaRefresh,
|
mynaRefresh,
|
||||||
|
mynaSearch,
|
||||||
mynaServers,
|
mynaServers,
|
||||||
mynaSpinner,
|
mynaSpinner,
|
||||||
mynaTrash,
|
mynaTrash,
|
||||||
|
mynaVideo,
|
||||||
mynaX,
|
mynaX,
|
||||||
mynaXCircle,
|
mynaXCircle,
|
||||||
} from "@ng-icons/mynaui/outline";
|
} from "@ng-icons/mynaui/outline";
|
||||||
|
|
||||||
export const APP_ICONS = {
|
export const APP_ICONS = {
|
||||||
|
mynaArchive,
|
||||||
mynaArrowLeft,
|
mynaArrowLeft,
|
||||||
mynaArrowLeftRight,
|
mynaArrowLeftRight,
|
||||||
mynaArrowRight,
|
mynaArrowRight,
|
||||||
@@ -39,6 +47,7 @@ export const APP_ICONS = {
|
|||||||
mynaCheckCircle,
|
mynaCheckCircle,
|
||||||
mynaChevronDown,
|
mynaChevronDown,
|
||||||
mynaChevronRight,
|
mynaChevronRight,
|
||||||
|
mynaCode,
|
||||||
mynaCog,
|
mynaCog,
|
||||||
mynaConfigVertical,
|
mynaConfigVertical,
|
||||||
mynaEdit,
|
mynaEdit,
|
||||||
@@ -48,13 +57,18 @@ export const APP_ICONS = {
|
|||||||
mynaFolder,
|
mynaFolder,
|
||||||
mynaFolderPlus,
|
mynaFolderPlus,
|
||||||
mynaHardDrive,
|
mynaHardDrive,
|
||||||
|
mynaImage,
|
||||||
|
mynaLink,
|
||||||
mynaLock,
|
mynaLock,
|
||||||
mynaLogout,
|
mynaLogout,
|
||||||
|
mynaMusic,
|
||||||
mynaPlus,
|
mynaPlus,
|
||||||
mynaRefresh,
|
mynaRefresh,
|
||||||
|
mynaSearch,
|
||||||
mynaServers,
|
mynaServers,
|
||||||
mynaSpinner,
|
mynaSpinner,
|
||||||
mynaTrash,
|
mynaTrash,
|
||||||
|
mynaVideo,
|
||||||
mynaX,
|
mynaX,
|
||||||
mynaXCircle,
|
mynaXCircle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ export class PanelState {
|
|||||||
readonly sortAsc = signal(true);
|
readonly sortAsc = signal(true);
|
||||||
readonly cursor = signal(0);
|
readonly cursor = signal(0);
|
||||||
readonly selected = signal<ReadonlySet<string>>(new Set());
|
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. */
|
/** Directories first, then the active sort column. ".." (parent) is pinned on top. */
|
||||||
readonly sorted = computed(() => {
|
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 key = this.sortKey();
|
||||||
const dir = this.sortAsc() ? 1 : -1;
|
const dir = this.sortAsc() ? 1 : -1;
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
@@ -69,6 +75,7 @@ export class PanelState {
|
|||||||
this.entries.set(res.entries);
|
this.entries.set(res.entries);
|
||||||
this.cursor.set(0);
|
this.cursor.set(0);
|
||||||
this.selected.set(new Set());
|
this.selected.set(new Set());
|
||||||
|
this.filter.set('');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
@@ -110,6 +117,13 @@ export class PanelState {
|
|||||||
void this.load(`${this.cwd()}/..`);
|
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 {
|
toggleSort(key: SortKey): void {
|
||||||
if (this.sortKey() === key) {
|
if (this.sortKey() === key) {
|
||||||
this.sortAsc.update((v) => !v);
|
this.sortAsc.update((v) => !v);
|
||||||
@@ -312,6 +326,12 @@ export class PanelsService {
|
|||||||
this.setActiveWorkspace(next.id);
|
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. */
|
/** Tab label: "Local" for a local/local pair, else the remote titles. */
|
||||||
workspaceName(workspace: Workspace): string {
|
workspaceName(workspace: Workspace): string {
|
||||||
const remoteTitles = [workspace.left, workspace.right]
|
const remoteTitles = [workspace.left, workspace.right]
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Injected by the bun process via BrowserWindow preload before any page script.
|
// Injected by the bun process via BrowserWindow preload before any page script.
|
||||||
|
// Brand-neutral name so rebrands never touch this file.
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
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). */
|
/** True when the window uses macOS hiddenInset chrome (tab bar fused with titlebar). */
|
||||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { RpcService } from './rpc.service';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
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 speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||||
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
||||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||||
@@ -16,10 +18,14 @@ export class SettingsService {
|
|||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// the default accent is not the CSS default — apply before first paint
|
||||||
|
this.applyAccent();
|
||||||
if (!this.rpc.available) return;
|
if (!this.rpc.available) return;
|
||||||
void this.rpc.request.getSettings({}).then((res) => {
|
void this.rpc.request.getSettings({}).then((res) => {
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
this.theme.set(res.settings.theme);
|
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.speedUnit.set(res.settings.speedUnit);
|
||||||
this.textSize.set(res.settings.textSize);
|
this.textSize.set(res.settings.textSize);
|
||||||
this.transferMode.set(res.settings.transferMode);
|
this.transferMode.set(res.settings.transferMode);
|
||||||
@@ -28,6 +34,7 @@ export class SettingsService {
|
|||||||
this.editor.set(res.settings.editor);
|
this.editor.set(res.settings.editor);
|
||||||
this.editorApp.set(res.settings.editorApp);
|
this.editorApp.set(res.settings.editorApp);
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
|
this.applyAccent();
|
||||||
this.applyTextSize();
|
this.applyTextSize();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -38,6 +45,18 @@ export class SettingsService {
|
|||||||
void this.persist();
|
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 {
|
setSpeedUnit(unit: AppSettings['speedUnit']): void {
|
||||||
this.speedUnit.set(unit);
|
this.speedUnit.set(unit);
|
||||||
void this.persist();
|
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 {
|
private applyTextSize(): void {
|
||||||
const size = this.textSize();
|
const size = this.textSize();
|
||||||
if (size === 'default') {
|
if (size === 'default') {
|
||||||
@@ -95,6 +123,8 @@ export class SettingsService {
|
|||||||
protected settings(): AppSettings {
|
protected settings(): AppSettings {
|
||||||
return {
|
return {
|
||||||
theme: this.theme(),
|
theme: this.theme(),
|
||||||
|
accent: this.accent(),
|
||||||
|
iconColors: this.iconColors(),
|
||||||
speedUnit: this.speedUnit(),
|
speedUnit: this.speedUnit(),
|
||||||
textSize: this.textSize(),
|
textSize: this.textSize(),
|
||||||
transferMode: this.transferMode(),
|
transferMode: this.transferMode(),
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import type { AccentTone } from '@shared/types';
|
||||||
import { PanelsService } from '../panels.service';
|
import { PanelsService } from '../panels.service';
|
||||||
import { IS_MAC_CHROME } from '../platform';
|
import { IS_MAC_CHROME } from '../platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
|
|
||||||
const THEMES = ['system', 'light', 'dark'] as const;
|
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 = [
|
const SPEED_UNITS = [
|
||||||
{ value: 'MBps', label: 'MB/s' },
|
{ value: 'MBps', label: 'MB/s' },
|
||||||
{ value: 'Mbps', label: 'Mbps' },
|
{ value: 'Mbps', label: 'Mbps' },
|
||||||
] as const;
|
] as const;
|
||||||
const TEXT_SIZES = ['compact', 'default', 'large'] 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 TRANSFER_MODES = ['single', 'multi'] as const;
|
||||||
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] 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>
|
</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">
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||||
<span class="text-xs text-muted-foreground">Text size</span>
|
<span class="text-xs text-muted-foreground">Text size</span>
|
||||||
<div class="flex rounded-md border border-border p-0.5">
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
@@ -273,6 +322,8 @@ export class TabBar {
|
|||||||
readonly settingsOpen = signal(false);
|
readonly settingsOpen = signal(false);
|
||||||
readonly isMacChrome = IS_MAC_CHROME;
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
readonly themes = THEMES;
|
readonly themes = THEMES;
|
||||||
|
readonly accents = ACCENTS;
|
||||||
|
readonly iconColorModes = ICON_COLOR_MODES;
|
||||||
readonly speedUnits = SPEED_UNITS;
|
readonly speedUnits = SPEED_UNITS;
|
||||||
readonly textSizes = TEXT_SIZES;
|
readonly textSizes = TEXT_SIZES;
|
||||||
readonly transferModes = TRANSFER_MODES;
|
readonly transferModes = TRANSFER_MODES;
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
--radius: 0.5rem;
|
--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.
|
/* Dark tokens apply when the user forces dark, or follows a dark system theme.
|
||||||
@@ -45,6 +52,12 @@
|
|||||||
--border: oklch(1 0 0 / 12%);
|
--border: oklch(1 0 0 / 12%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -67,6 +80,12 @@
|
|||||||
--border: oklch(1 0 0 / 12%);
|
--border: oklch(1 0 0 / 12%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
--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-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--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-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -125,6 +151,43 @@
|
|||||||
--path-ctl-h: 32px;
|
--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,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user