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 @@
+
-
-
-
-
+
+
+
+
+