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:
1 parent
fab9add5d5
commit
5b569b6835
108 files changed
+6785
-279
No files matched your search
@@ -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 }));
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user