Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes

- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas,
  login rate limiting, audit log and backups
- Ghidra headless decompilation (ARM64 natives built from source), binary_info tool,
  clean room / deep workflow per session
- Recreate skill and commands for analysing and re-building old software
- File checkpoints with rewind/restore, background shells (bash_output, kill_shell)
- web_search tool (DuckDuckGo, SearXNG, Brave, Tavily)
- In-app dialogs and toasts instead of browser dialogs
- 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One,
  Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:12:30 +01:00
1 parent fab9add5d5
commit 5b569b6835
108 files changed
+6785 -279

No files matched your search

+39 -24
View File
@@ -6,6 +6,8 @@ import { Spinner } from "./Activity";
import { Diff } from "./Diff";
import { Markdown } from "./Markdown";
import { GitPanel, type GitStatus } from "./GitPanel";
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
import { confirmDialog, promptDialog, toast } from "../dialogs";
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
@@ -31,12 +33,13 @@ interface OpenFile {
mtime?: number;
binary: boolean;
tooLarge?: boolean;
preview?: PreviewKind;
}
/** What the right pane shows. */
type Pane = { kind: "file"; file: OpenFile } | { kind: "diff"; title: string; diff: string } | { kind: "none" };
const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
const IMAGE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
const rawUrl = (p: string, download = false) => `/api/files/raw?path=${encodeURIComponent(p)}${download ? "&download=true" : ""}`;
const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
@@ -44,6 +47,9 @@ const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
function icon(e: Entry) {
if (e.is_dir) return "📁";
if (IMAGE.test(e.name)) return "🖼";
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return "🎵";
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return "🧊";
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return "📑";
if (/\.(md|txt)$/i.test(e.name)) return "📝";
return "📄";
}
@@ -112,17 +118,24 @@ export function Files({ onMenu }: { onMenu: () => void }) {
const dirty = pane.kind === "file" && pane.file.content !== pane.file.saved;
const confirmDiscard = () => !dirty || confirm(t("files.discardChanges"));
const confirmDiscard = async () => !dirty || (await confirmDialog({ message: t("files.discardChanges"), danger: true, confirmLabel: t("files.discard") }));
const openFile = async (path: string) => {
if (!confirmDiscard()) return;
if (!(await confirmDiscard())) return;
const kind = previewKind(path);
// Previewable files (except SVG, which can also be edited) are not loaded as text.
if (kind && !/\.svg$/i.test(path)) {
setPane({ kind: "file", file: { path, content: "", saved: "", binary: true, preview: kind } });
setMobileEditor(true);
return;
}
try {
const r = await get<{ content?: string; binary: boolean; mtime?: number; too_large?: boolean }>(`/files/read?path=${encodeURIComponent(path)}`);
setPane({ kind: "file", file: { path, content: r.content ?? "", saved: r.content ?? "", mtime: r.mtime, binary: r.binary, tooLarge: r.too_large } });
setPreview(/\.md$/i.test(path));
setPreview(/\.(md|svg)$/i.test(path));
setMobileEditor(true);
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
@@ -135,8 +148,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
setPane({ kind: "file", file: { ...f, saved: f.content, mtime: r.mtime } });
void refreshGit();
} catch (e: any) {
if (e.status === 409 && confirm(t("files.conflict"))) return save(true);
alert(e.message);
if (e.status === 409 && (await confirmDialog({ message: t("files.conflict"), danger: true, confirmLabel: t("files.overwrite") }))) return save(true);
toast(e.message);
}
},
[pane, refreshGit, t],
@@ -148,12 +161,12 @@ export function Files({ onMenu }: { onMenu: () => void }) {
if (listing) await load(listing.path);
void refreshGit();
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
const newEntry = (isDir: boolean) => {
const name = prompt(isDir ? t("files.newFolderName") : t("files.newFileName"));
const newEntry = async (isDir: boolean) => {
const name = await promptDialog({ message: isDir ? t("files.newFolderName") : t("files.newFileName") });
if (!name || !listing) return;
const path = `${listing.path}/${name}`;
void act(async () => {
@@ -162,18 +175,18 @@ export function Files({ onMenu }: { onMenu: () => void }) {
});
};
const rename = (e: Entry) => {
const name = prompt(t("files.renameTo"), e.name);
const rename = async (e: Entry) => {
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
};
const duplicate = (e: Entry) => {
const name = prompt(t("files.copyTo"), `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}`);
const duplicate = async (e: Entry) => {
const name = await promptDialog({ message: t("files.copyTo"), defaultValue: `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}` });
if (name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}`, copy: true }));
};
const remove = (e: Entry) => {
if (confirm(t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }))) {
const remove = async (e: Entry) => {
if (await confirmDialog({ message: t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }), danger: true, confirmLabel: t("common.delete") })) {
void act(() => del(`/files/delete?path=${encodeURIComponent(e.path)}`));
if (pane.kind === "file" && pane.file.path.startsWith(e.path)) setPane({ kind: "none" });
}
@@ -183,7 +196,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
if (!listing) return;
for (const f of [...files]) {
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(`${listing.path}/${f.name}`)}`, { method: "PUT", body: f, credentials: "same-origin" });
if (!res.ok) alert(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
if (!res.ok) toast(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
}
await load(listing.path);
void refreshGit();
@@ -220,7 +233,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
>
<div className="files-toolbar">
{roots.length > 1 && (
<select value={root?.path ?? ""} onChange={(e) => confirmDiscard() && load(e.target.value)} aria-label={t("images.folder")}>
<select value={root?.path ?? ""} onChange={(e) => { const v = e.target.value; void confirmDiscard().then((ok): unknown => ok && load(v)); }} aria-label={t("images.folder")}>
{roots.map((r) => (
<option key={r.path} value={r.path}>
{r.name}
@@ -335,14 +348,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
return (
<div className="editor-pane">
<div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => confirmDiscard() && setMobileEditor(false)}>
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
←
</button>
<b className="grow" title={f.path}>
{baseName(f.path)}
{dirty && <span className="dirty"> ●</span>}
</b>
{isMd && (
{(isMd || isImage) && !f.preview && (
<button className="small" onClick={() => setPreview(!preview)}>
{preview ? t("files.edit") : t("files.preview")}
</button>
@@ -356,7 +369,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button>
)}
</div>
{isImage ? (
{f.preview ? (
<FilePreview path={f.path} kind={f.preview} />
) : isImage && preview ? (
<div className="pane-scroll image-preview">
<img src={rawUrl(f.path)} alt={baseName(f.path)} />
</div>
@@ -422,10 +437,10 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button>
<button
className="small"
onClick={() => {
const url = prompt(t("git.cloneUrl"));
onClick={async () => {
const url = await promptDialog({ message: t("git.cloneUrl"), placeholder: "https://…/repo.git" });
if (!url) return;
const name = prompt(t("git.cloneFolder"), baseName(url).replace(/\.git$/, ""));
const name = await promptDialog({ message: t("git.cloneFolder"), defaultValue: baseName(url).replace(/\.git$/, "") });
if (name) void act(() => post("/git", { path: `${listing.path}/${name}`, action: "clone", url }));
}}
>