diff --git a/panel.html b/panel.html index 24e22b6..03dd768 100644 --- a/panel.html +++ b/panel.html @@ -76,6 +76,51 @@ .panel-btn.copied { color: #a6e3a1; } + .panel-btn.error { + color: #f38ba8; + } + .panel-btn svg { + display: block; + stroke: currentColor; + stroke-width: 1.3; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + } + .panel-btn.active { + background: #313244; + color: #cdd6f4; + } + .panel-btn.changed { + color: #f9e2af; + } + .panel-editor { + flex: 1; + min-height: 0; + resize: none; + border: none; + outline: none; + padding: 16px 20px; + background: transparent; + color: inherit; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 12px; + line-height: 1.55; + } + .panel-editor[hidden] { + display: none; + } + .panel-lang { + margin-right: auto; + background: #313244; + color: #a6adc8; + border: none; + border-radius: 6px; + padding: 3px 4px; + font: inherit; + font-size: 11px; + cursor: default; + } #panel-content { flex: 1; min-height: 0; @@ -159,10 +204,31 @@
+ - - - + + + + +
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7419359..f86b34d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -88,6 +88,17 @@ pub(crate) fn project_archive_dir(project: &str) -> Option { pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> { let paths = Paths::real(); let expanded = expand_home(&paths, dir); + if !expanded.is_absolute() { + return Err(format!("Archiv-Ordner muss ein absoluter Pfad sein: {}", expanded.display())); + } + // Zu weit gefasst? Ein Vorfahre des Home (inkl. Home selbst und Root) würde + // claude über additionalDirectories weiten Zugriff geben — das lehnen wir ab. + if paths.home.starts_with(&expanded) { + return Err(format!( + "Archiv-Ordner zu weit gefasst: {}. Bitte einen spezifischen Unterordner wählen.", + expanded.display() + )); + } fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?; let contracted = contract_home(&paths, &expanded); let mut cfg = read_project_config_in(&paths, project)?; @@ -638,8 +649,11 @@ fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> { fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) } -/// "~/x" relativ zum Home auflösen; alles andere unverändert. +/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert. fn expand_home(paths: &Paths, p: &str) -> PathBuf { + if p == "~" { + return paths.home.clone(); + } match p.strip_prefix("~/") { Some(rest) => paths.home.join(rest), None => PathBuf::from(p), @@ -1460,6 +1474,14 @@ fn register_mcp_server(pool_dir: &std::path::Path) { let Ok(exe) = std::env::current_exe() else { return; }; + // Tool nicht deferren, sonst sieht das Modell write_panel nicht und schreibt + // den Entwurf in den Chat statt ins Panel. + let desired = serde_json::json!({ + "type": "stdio", + "command": exe.to_string_lossy(), + "args": ["--mcp-panel"], + "alwaysLoad": true, + }); let cfg = pool_dir.join(".claude.json"); let mut v = fs::read_to_string(&cfg) .ok() @@ -1473,18 +1495,13 @@ fn register_mcp_server(pool_dir: &std::path::Path) { else { return; }; + // Schon korrekt und kein Altkey -> nichts schreiben. `.claude.json` ist + // claudes Live-State; wir fassen sie nur an, wenn der Eintrag fehlt/abweicht. + if servers.get("aicontrol").is_none() && servers.get(PANEL_MCP_SERVER) == Some(&desired) { + return; + } servers.remove("aicontrol"); // alter Key vor Umbenennung - servers.insert( - PANEL_MCP_SERVER.into(), - serde_json::json!({ - "type": "stdio", - "command": exe.to_string_lossy(), - "args": ["--mcp-panel"], - // Tool nicht deferren, sonst sieht das Modell write_panel nicht und - // schreibt den Entwurf in den Chat statt ins Panel. - "alwaysLoad": true, - }), - ); + servers.insert(PANEL_MCP_SERVER.into(), desired); if let Ok(out) = serde_json::to_string_pretty(&v) { let _ = fs::write(&cfg, out + "\n"); } @@ -2137,6 +2154,17 @@ fn terminal_font_size() -> u32 { .unwrap_or(13) } +/// Standard-Sprache der Rechtschreibprüfung (settings.json: spellcheckLang), +/// Default „de". Pro Text im Panel überschreibbar. +#[tauri::command] +fn spellcheck_lang() -> String { + fs::read_to_string(Paths::real().config_dir().join(APP_SETTINGS_FILE)) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v["spellcheckLang"].as_str().map(str::to_string)) + .unwrap_or_else(|| "de".to_string()) +} + /// Setzt die Schriftgröße; erhält übrige App-settings. #[tauri::command] fn set_terminal_font_size(size: u32) -> Result<(), String> { @@ -2269,6 +2297,30 @@ fn panel_archive_cmd(project: String, dir: Option) -> Result tauri::Builder { if let tauri::WindowEvent::Destroyed = event { if window.label().starts_with("panel-") { use tauri::{Emitter, Manager}; - let _ = window.app_handle().emit("panel-attached", ()); + let _ = window.app_handle().emit("panel-window-closed", ()); } else { terminal::close(window); } @@ -2529,12 +2581,14 @@ fn terminal_builder(project: String) -> tauri::Builder { terminal::open_panel_window, panel_archive_dir_cmd, panel_archive_cmd, + reveal_path_cmd, // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name. list_projects, project_icon, pool_label, terminal_font_size, - set_terminal_font_size + set_terminal_font_size, + spellcheck_lang ]) } diff --git a/src/panel-view.ts b/src/panel-view.ts index fc7cf24..c8d1bb2 100644 --- a/src/panel-view.ts +++ b/src/panel-view.ts @@ -58,11 +58,20 @@ export function initPanelView(opts: { modeBtn: HTMLElement; titleEl?: HTMLElement; editBtn?: HTMLElement; - /// Wird mit dem neuen Rohtext aufgerufen, wenn der Titel geändert wurde. + editContentBtn?: HTMLElement; + langSelect?: HTMLSelectElement; + /// Standard-Sprache der Rechtschreibprüfung (aus den App-Settings). + defaultLang?: string; + /// Wird mit dem neuen Rohtext aufgerufen, wenn Titel oder Inhalt geändert + /// wurden. onCommit?: (text: string) => void; }): PanelView { let rawText = ""; let rendered = true; + // Content-Edit-Zustand: während des Editierens werden eingehende Updates + // gepuffert statt angewandt. + let editing = false; + let pending: string | null = null; function draw() { if (rendered) { @@ -83,12 +92,7 @@ export function initPanelView(opts: { opts.copyBtn.addEventListener("click", async () => { await navigator.clipboard.writeText(rawText); opts.copyBtn.classList.add("copied"); - const label = opts.copyBtn.textContent; - opts.copyBtn.textContent = "Kopiert"; - setTimeout(() => { - opts.copyBtn.classList.remove("copied"); - opts.copyBtn.textContent = label; - }, 1200); + setTimeout(() => opts.copyBtn.classList.remove("copied"), 1200); }); // Titel-Edit: Edit-Button macht den Titel editierbar, Enter/Blur schreibt die @@ -124,14 +128,92 @@ export function initPanelView(opts: { titleEl.addEventListener("blur", commit); } + // Content-Edit: Button schaltet den Inhalt auf eine Rohtext-Textarea. Speichern + // (Button erneut oder Cmd/Ctrl+Enter) schreibt zurück, Escape verwirft. + // Eingehende Updates während des Editierens werden gepuffert. + if (opts.editContentBtn && opts.onCommit) { + const editBtn = opts.editContentBtn; + const editor = document.createElement("textarea"); + editor.className = "panel-editor"; + editor.spellcheck = true; + editor.hidden = true; + opts.content.after(editor); + + // Sprache der Rechtschreibprüfung: Default aus den Settings, per Selector + // pro Text überschreibbar. + let lang = opts.defaultLang || "de"; + const sel = opts.langSelect; + if (sel) { + if ([...sel.options].some((o) => o.value === lang)) sel.value = lang; + else lang = sel.value; + sel.addEventListener("change", () => { + lang = sel.value; + editor.lang = lang; + // Neuprüfung erzwingen. + editor.spellcheck = false; + editor.spellcheck = true; + if (!editor.hidden) editor.focus(); + }); + } + editor.lang = lang; + + const leave = () => { + editing = false; + editor.hidden = true; + opts.content.hidden = false; + editBtn.classList.remove("active", "changed"); + }; + const enter = () => { + editing = true; + pending = null; + editor.value = rawText; + opts.content.hidden = true; + editor.hidden = false; + editBtn.classList.add("active"); + editor.focus(); + }; + const save = () => { + const v = editor.value; + leave(); + opts.onCommit!(v); // schreibt Datei -> panel-update -> set() rendert + }; + const cancel = () => { + const p = pending; + leave(); + if (p !== null) applyText(p); + else draw(); + }; + editBtn.addEventListener("click", () => (editing ? save() : enter())); + editor.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + save(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + }); + } + + function applyText(text: string) { + rawText = text; + if (titleEl && titleEl.getAttribute("contenteditable") !== "true") { + titleEl.textContent = firstLine(text); + } + draw(); + } + draw(); return { set(text: string) { - rawText = text; - if (titleEl && titleEl.getAttribute("contenteditable") !== "true") { - titleEl.textContent = firstLine(text); + // Während einer Inhalts-Bearbeitung nicht überschreiben — puffern und den + // Edit-Button als „geändert" markieren. + if (editing) { + pending = text; + opts.editContentBtn?.classList.add("changed"); + return; } - draw(); + applyText(text); }, raw: () => rawText, }; diff --git a/src/panel.ts b/src/panel.ts index 295789d..9d4af7e 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -2,7 +2,7 @@ import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/500.css"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; -import { listen } from "@tauri-apps/api/event"; +import { listen, emit } from "@tauri-apps/api/event"; import { initPanelView } from "./panel-view"; // Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt @@ -16,6 +16,9 @@ const view = initPanelView({ modeBtn: document.getElementById("panel-mode")!, titleEl: document.querySelector(".panel-title") as HTMLElement, editBtn: document.getElementById("panel-title-edit")!, + editContentBtn: document.getElementById("panel-content-edit")!, + langSelect: document.getElementById("panel-lang") as HTMLSelectElement, + defaultLang: await invoke("spellcheck_lang"), onCommit: (text) => invoke("panel_set", { project, text }), }); @@ -25,8 +28,11 @@ await listen("panel-update", (e) => view.set(e.payload)); // Archivieren: wie im angedockten Panel — Ordner nehmen oder per Dialog wählen. const archiveBtn = document.getElementById("panel-archive")!; +function flashBtn(btn: HTMLElement, cls: string) { + btn.classList.add(cls); + setTimeout(() => btn.classList.remove(cls), 1400); +} archiveBtn.addEventListener("click", async () => { - const orig = archiveBtn.textContent; try { const configured = await invoke("panel_archive_dir_cmd", { project, @@ -38,20 +44,20 @@ archiveBtn.addEventListener("click", async () => { if (!chosen) return; dir = chosen as string; } - await invoke("panel_archive_cmd", { project, dir }); - archiveBtn.textContent = "Archiviert"; - archiveBtn.classList.add("copied"); + const path = await invoke("panel_archive_cmd", { project, dir }); + flashBtn(archiveBtn, "copied"); + invoke("reveal_path_cmd", { path }); } catch { - archiveBtn.textContent = "Fehler"; + flashBtn(archiveBtn, "error"); } - setTimeout(() => { - archiveBtn.textContent = orig; - archiveBtn.classList.remove("copied"); - }, 1400); }); -// „Andocken": Fenster schließen — terminal.rs meldet `panel-attached`, das -// angedockte Panel im Terminal-Fenster erscheint wieder. -document - .getElementById("panel-dock")! - .addEventListener("click", () => win.close()); +// „Andocken": angedocktes Panel wieder einblenden, dann dieses Fenster schließen. +document.getElementById("panel-dock")!.addEventListener("click", async () => { + await emit("panel-attached"); + win.close(); +}); + +// „Schließen": Fenster zu, ohne wieder anzudocken (Panel bleibt aus, bis ein +// neuer Entwurf kommt). +document.getElementById("panel-close")!.addEventListener("click", () => win.close()); diff --git a/src/terminal.ts b/src/terminal.ts index 052af67..ba882c5 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -553,6 +553,9 @@ const view = initPanelView({ modeBtn: document.getElementById("panel-mode")!, titleEl: panel.querySelector(".panel-title") as HTMLElement, editBtn: document.getElementById("panel-title-edit")!, + editContentBtn: document.getElementById("panel-content-edit")!, + langSelect: document.getElementById("panel-lang") as HTMLSelectElement, + defaultLang: await invoke("spellcheck_lang"), onCommit: (text) => invoke("panel_set", { project, text }), }); @@ -583,6 +586,11 @@ await listen("panel-attached", () => { detached = false; if (hasContent) showPanel(); }); +// Abgelöstes Fenster geschlossen (per ✕ oder OS): angedocktes Panel bleibt +// ausgeblendet, aber der nächste Entwurf darf es wieder einblenden. +await listen("panel-window-closed", () => { + detached = false; +}); document .getElementById("panel-detach")! @@ -591,8 +599,11 @@ document.getElementById("panel-hide")!.addEventListener("click", hidePanel); // Archivieren: konfigurierten Ordner nehmen, sonst per Dialog wählen (setzt ihn). const archiveBtn = document.getElementById("panel-archive")!; +function flashBtn(btn: HTMLElement, cls: string) { + btn.classList.add(cls); + setTimeout(() => btn.classList.remove(cls), 1400); +} archiveBtn.addEventListener("click", async () => { - const orig = archiveBtn.textContent; try { const configured = await invoke("panel_archive_dir_cmd", { project, @@ -604,17 +615,13 @@ archiveBtn.addEventListener("click", async () => { if (!chosen) return; dir = chosen as string; } - await invoke("panel_archive_cmd", { project, dir }); - archiveBtn.textContent = "Archiviert"; - archiveBtn.classList.add("copied"); + const path = await invoke("panel_archive_cmd", { project, dir }); + flashBtn(archiveBtn, "copied"); + invoke("reveal_path_cmd", { path }); } catch (e) { - archiveBtn.textContent = "Fehler"; + flashBtn(archiveBtn, "error"); invoke("term_log", { msg: `archive: ${e}` }); } - setTimeout(() => { - archiveBtn.textContent = orig; - archiveBtn.classList.remove("copied"); - }, 1400); }); // Splitter: Panelbreite ziehen. diff --git a/terminal.html b/terminal.html index 77953b6..585f270 100644 --- a/terminal.html +++ b/terminal.html @@ -269,6 +269,51 @@ .panel-btn.copied { color: #a6e3a1; } + .panel-btn.error { + color: #f38ba8; + } + .panel-btn svg { + display: block; + stroke: currentColor; + stroke-width: 1.3; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + } + .panel-btn.active { + background: #313244; + color: #cdd6f4; + } + .panel-btn.changed { + color: #f9e2af; + } + .panel-editor { + flex: 1; + min-height: 0; + resize: none; + border: none; + outline: none; + padding: 14px 16px; + background: transparent; + color: inherit; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 12px; + line-height: 1.55; + } + .panel-editor[hidden] { + display: none; + } + .panel-lang { + margin-right: auto; + background: #313244; + color: #a6adc8; + border: none; + border-radius: 6px; + padding: 3px 4px; + font: inherit; + font-size: 11px; + cursor: default; + } #panel-content { flex: 1; min-height: 0; @@ -372,11 +417,31 @@
+ - - - - + + + + +