Bugfix-Runde Panel/Archiv: Datenverlust, Suche, Löschen, Markdown
- ProjectConfig reicht unbekannte Keys durch (serde flatten). Bisher verlor jeder read-modify-write die Felder neuerer Versionen; so ging archiveHome bei der Pool-Zuweisung durch einen älteren Build verloren. - Archiv-Suche: Nutzereingabe wird in einen FTS5-Ausdruck übersetzt, der nicht scheitern kann. ai-control, C++ oder eine halbe Phrase warfen bisher einen Syntaxfehler — bei der Live-Suche in jedem Tippzwischenstand. - commands_delete: Befehlstext als Identität, Indizes werden geprüft. Bisher Panik oder Löschen der falschen Kachel, wenn die Panel-Indizes der Datei hinterherhingen. - Archivieren überschreibt keine Datei mehr: Der Stempel hat Minutenauflösung, bei Kollision wird -2, -3, ... angehängt. - archive_panel prüft erst auf leeres Panel, dann wird das Archiv-Home gesetzt; bisher blieb ein umkonfiguriertes Home trotz Meldung "nicht archiviert". - Kaputte .claude.json eines Pools wird nicht mehr durch eine frische ersetzt; nur eine fehlende Datei rechtfertigt das. - Archiv-Formular leert beim Schließen — sonst galten Ordner und Schlagwörter des vorigen Dokuments unbemerkt fürs nächste. - Markdown fürs Panel entschärft (src/markdown.ts): rohes HTML wird als Text ausgegeben, Link- und Bildziele auf harmlose Schemata beschränkt. Das Panel teilt sich den Webview mit dem Terminal, dessen term_write in die PTY schreibt; ohne CSP war das ein Weg von Fremdinhalt zu Befehlsausführung. Regressionstests je Fix: 73 Rust-Tests, 35 Frontend-Tests.
This commit is contained in:
@@ -40,6 +40,26 @@ describe("initArchiveForm", () => {
|
||||
expect(root.hidden).toBe(true);
|
||||
});
|
||||
|
||||
/// Sonst gelten Ordner und Schlagwörter des vorigen Dokuments unbemerkt für
|
||||
/// das nächste — wer direkt Enter drückt, archiviert es falsch einsortiert.
|
||||
it("startet nach dem Archivieren wieder leer", () => {
|
||||
const { form, onSubmit, root, inputs } = setup();
|
||||
form.toggle();
|
||||
inputs[0].value = "konzepte/panel";
|
||||
inputs[1].value = "Beschreibung A";
|
||||
inputs[2].value = "panel, wiki";
|
||||
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||
|
||||
form.toggle();
|
||||
expect(inputs.map((i) => i.value)).toEqual(["", "", ""]);
|
||||
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
|
||||
expect(onSubmit).toHaveBeenLastCalledWith({
|
||||
folder: undefined,
|
||||
description: undefined,
|
||||
tags: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("Enter schickt ab, Escape schließt ohne Abschicken", () => {
|
||||
const { form, onSubmit, root } = setup();
|
||||
form.toggle();
|
||||
|
||||
@@ -40,8 +40,15 @@ export function initArchiveForm(
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
// Beim Schließen leeren: Das Formular gehört zum Fenster, nicht zum Dokument.
|
||||
// Stehengebliebene Werte würden beim nächsten Öffnen unbemerkt für ein anderes
|
||||
// Dokument gelten — wer direkt Enter drückt, archiviert es mit dem Ordner und
|
||||
// den Schlagwörtern des vorigen.
|
||||
const close = () => {
|
||||
form.hidden = true;
|
||||
folder.value = "";
|
||||
desc.value = "";
|
||||
tags.value = "";
|
||||
};
|
||||
const open = () => {
|
||||
const r = anchor.getBoundingClientRect();
|
||||
|
||||
@@ -94,12 +94,14 @@ describe("initCommandsView", () => {
|
||||
expect(view.empty()).toBe(true);
|
||||
});
|
||||
|
||||
it("meldet Löschen mit Record- und Eintrags-Index", () => {
|
||||
/// Der Befehlstext geht mit, weil die Indizes der Datei hinterherhinken
|
||||
/// können; das Backend gleicht damit ab, bevor es löscht.
|
||||
it("meldet Löschen mit Record-Index, Eintrags-Index und Befehl", () => {
|
||||
const onDelete = vi.fn();
|
||||
const view = initCommandsView(document.getElementById("c")!, onDelete);
|
||||
view.set(jsonl);
|
||||
const dels = document.querySelectorAll<HTMLElement>(".cmd-del");
|
||||
dels[1].click(); // zweite Kachel im Record 1
|
||||
expect(onDelete).toHaveBeenCalledWith(1, 1);
|
||||
expect(onDelete).toHaveBeenCalledWith(1, 1, "pwd");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ function deleteBtn(onClick: () => void): HTMLButtonElement {
|
||||
|
||||
export function initCommandsView(
|
||||
container: HTMLElement,
|
||||
onDelete: (line: number, entry: number) => void,
|
||||
onDelete: (line: number, entry: number, cmd: string) => void,
|
||||
): CommandsView {
|
||||
let count = 0;
|
||||
|
||||
@@ -128,7 +128,7 @@ export function initCommandsView(
|
||||
note.textContent = entry.note;
|
||||
body.append(note);
|
||||
}
|
||||
tile.append(body, copyBtn(() => entry.cmd), deleteBtn(() => onDelete(i, j)));
|
||||
tile.append(body, copyBtn(() => entry.cmd), deleteBtn(() => onDelete(i, j, entry.cmd)));
|
||||
block.append(tile);
|
||||
});
|
||||
container.append(block);
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,64 @@
|
||||
/// Markdown-Rendering fürs Panel — eine gemeinsame, entschärfte marked-Instanz
|
||||
/// für Dokument-Tab und Wiki.
|
||||
///
|
||||
/// Warum überhaupt entschärft: marked reicht seit v5 rohes HTML unverändert
|
||||
/// durch (die alte `sanitize`-Option gibt es nicht mehr), und das Ergebnis geht
|
||||
/// per innerHTML ins Panel. Das Panel liegt im selben Webview wie das Terminal,
|
||||
/// dessen `term_write`-Command die PTY über das Fensterlabel adressiert —
|
||||
/// eingeschleustes Skript könnte also Eingaben in die laufende Shell schreiben.
|
||||
/// Archiv-Dokumente sind normalerweise die eigenen, aber sobald eines von außen
|
||||
/// kommt (Git-Sync, geteiltes Verzeichnis, eingefügter Fremdtext), wird aus
|
||||
/// Markdown-Anzeige Befehlsausführung. Eine CSP gibt es nicht als zweite
|
||||
/// Schranke (tauri.conf.json: `csp: null`).
|
||||
///
|
||||
/// Der Ansatz kommt ohne Sanitizer-Abhängigkeit aus, weil er an der Quelle
|
||||
/// ansetzt statt am fertigen HTML: Rohes HTML wird als Text ausgegeben, und
|
||||
/// Link- wie Bildziele müssen ein harmloses Schema haben.
|
||||
|
||||
import { marked, type Tokens } from "marked";
|
||||
|
||||
/// Schemata, die im Panel etwas anzuzeigen haben. Alles andere — allen voran
|
||||
/// `javascript:`, aber auch `data:` (SVG mit Skript) und `file:` — fliegt raus.
|
||||
const OK_SCHEME = /^(https?:|mailto:|#|\/|\.{0,2}\/)/i;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/// Leerer Link statt gefährlichem Ziel — der Text bleibt sichtbar, der Klick
|
||||
/// tut nichts.
|
||||
function safeHref(href: string): string {
|
||||
const h = href.trim();
|
||||
// Steuerzeichen entfernen: `java\nscript:` ist sonst ein Umgehungsweg.
|
||||
const clean = h.replace(/[\u0000-\u001f\u007f]/g, "");
|
||||
return OK_SCHEME.test(clean) ? clean : "";
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
// Block- und Inline-HTML laufen beide hier durch: als Text anzeigen, nicht als
|
||||
// Markup interpretieren.
|
||||
renderer.html = ({ raw }: Tokens.HTML | Tokens.Tag) => escapeHtml(raw);
|
||||
|
||||
renderer.link = function ({ href, title, tokens }: Tokens.Link) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
const safe = safeHref(href);
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
return safe ? `<a href="${escapeHtml(safe)}"${t}>${text}</a>` : `<a${t}>${text}</a>`;
|
||||
};
|
||||
|
||||
renderer.image = ({ href, title, text }: Tokens.Image) => {
|
||||
const safe = safeHref(href);
|
||||
if (!safe) return escapeHtml(text);
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
return `<img src="${escapeHtml(safe)}" alt="${escapeHtml(text)}"${t}>`;
|
||||
};
|
||||
|
||||
/// Markdown zu HTML, sicher genug für innerHTML.
|
||||
export function renderMarkdown(src: string): string {
|
||||
return marked.parse(src, { async: false, renderer });
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { marked } from "marked";
|
||||
import { renderMarkdown } from "./markdown";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { flash } from "./commands-view";
|
||||
|
||||
@@ -123,7 +123,7 @@ export function initPanelView(opts: {
|
||||
function draw() {
|
||||
if (rendered) {
|
||||
opts.content.className = "md";
|
||||
opts.content.innerHTML = marked.parse(rawText, { async: false });
|
||||
opts.content.innerHTML = renderMarkdown(rawText);
|
||||
if (opts.onWikiLink) linkWikiRefs(opts.content, opts.onWikiLink);
|
||||
} else {
|
||||
opts.content.className = "raw";
|
||||
|
||||
+3
-2
@@ -75,8 +75,9 @@ export async function wirePanel(
|
||||
onCommit: (text) => invoke("panel_set", { project, text }),
|
||||
onWikiLink: openWiki,
|
||||
});
|
||||
const cmdView = initCommandsView(document.getElementById("commands-content")!, (line, entry) =>
|
||||
invoke("commands_delete", { project, line, entry }),
|
||||
const cmdView = initCommandsView(
|
||||
document.getElementById("commands-content")!,
|
||||
(line, entry, cmd) => invoke("commands_delete", { project, line, entry, cmd }),
|
||||
);
|
||||
// Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und
|
||||
// archivierbar); der Sprung ins Wiki geht von dort aus.
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
/// Fremdtext und gehen nie durch innerHTML; nur der Markdown-Rumpf läuft wie
|
||||
/// im Entwurf durch marked.
|
||||
|
||||
import { marked } from "marked";
|
||||
import { renderMarkdown } from "./markdown";
|
||||
import { linkWikiRefs } from "./panel-view";
|
||||
|
||||
interface DocEntry {
|
||||
@@ -195,7 +195,7 @@ export function initWikiView(
|
||||
}
|
||||
const body = document.createElement("div");
|
||||
body.className = "wiki-body";
|
||||
body.innerHTML = marked.parse(d.markdown, { async: false });
|
||||
body.innerHTML = renderMarkdown(d.markdown);
|
||||
linkWikiRefs(body, onLink);
|
||||
container.append(body);
|
||||
if (d.backlinks.length) {
|
||||
|
||||
Reference in New Issue
Block a user