diff --git a/README.md b/README.md index 9ec6385..2a8bfa9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ai-control +# aICentral -Pool and session management for [Claude Code](https://claude.com/claude-code) on macOS — a tray app with a built-in terminal. Linux and Windows integration to follow. +**Get Claude Code organized** — pool and session management for [Claude Code](https://claude.com/claude-code), as a tray app with a built-in terminal and a draft panel. macOS and Linux; Windows support follows soon. (Repository and binary name: `ai-control`.) > All code in this project was generated by Claude (Claude Code). @@ -8,14 +8,24 @@ Pool and session management for [Claude Code](https://claude.com/claude-code) on ## Purpose -Running Claude Code with multiple accounts or credential sets means hitting the right `CLAUDE_CONFIG_DIR` (plus keychain entry) for every session. ai-control turns that into clicks: +Running Claude Code with multiple accounts or credential sets means hitting the right `CLAUDE_CONFIG_DIR` (plus keychain entry) for every session. aICentral turns that into clicks: - **Pools** — named credential sets (OAuth login or API key) under `~/.config/ai-control/pools/`. Each pool is a self-contained Claude config directory with its own keychain entry. - **Projects** — arbitrary directories, mapped through a registry (`~/.config/ai-control/projects.json`, name → path; paths under home are stored as `~/…` and therefore stable across machines). Each project is assigned a pool, plus terminal settings (theme, icon, window title). -- **Sessions** — a built-in terminal per project (xterm.js + PTY) that launches Claude Code with the pool's config directory as `CLAUDE_CONFIG_DIR`. Every terminal runs as its own process with its own Dock icon and Cmd-Tab entry. -- **Tray** — the app itself is a pure menu-bar app without a Dock entry. The tray menu lists all projects with icon and status dot (green = running); clicking starts the project or brings the running terminal to the front. +- **Sessions** — a built-in terminal per project (xterm.js + PTY) that launches Claude Code with the pool's config directory as `CLAUDE_CONFIG_DIR`. Every terminal runs as its own process — on macOS with its own Dock icon and Cmd-Tab entry, on Linux as its own window with its own app id. +- **Tray** — the app itself is a pure tray app. Clicking the tray icon opens a popup listing all projects with icon and status dot (green = running); clicking a project starts it or brings the running terminal to the front. +- **Text panel** — a draft panel beside the terminal. Claude writes longer texts (ADRs, emails, commit messages, specs) into it through a bundled MCP tool instead of flooding the chat; the panel text is mouse-selectable, copyable via button, and archivable per project. - **Session watcher** — detects the end of a session by the terminal process disappearing and then (opt-in) syncs the Git repository containing the project: add → commit → pull --rebase → push. +## Text panel + +The app ships an MCP stdio server (`ai-control --mcp-panel`, server key `text-panel`) and provisions it into every pool, including the tool permission — no per-pool setup: + +- **`write_panel`** — Claude places a Markdown draft in the panel next to the terminal instead of printing it as chat prose. +- **`archive_panel`** — saves the current draft as a Markdown file into the project's archive directory (`archiveDir` in the project's `ai-control.json`). + +The panel docks into the terminal window and can be detached into its own window; the title is taken from the draft's first heading and can be edited, the content is editable too. Spell checking follows `spellcheckLang` (per-text override in the panel). + ## Screenshots | Pools | Usage | @@ -26,26 +36,61 @@ Running Claude Code with multiple accounts or credential sets means hitting the |---|---| | ![Tray menu](docs/tray.png) | ![Session terminal](docs/terminal.png) | +## Installation + +### macOS + +Prebuilt DMGs are attached to the releases. The bundles are **not signed or notarized** — an Apple Developer ID costs €99/year, which this project doesn't have. If you'd like to change that: [Buy me a coffee](https://buymeacoffee.com/marcusH). macOS quarantines the unsigned download; after copying `ai-control.app` to `~/Applications`, clear it with: + +```sh +xattr -d com.apple.quarantine ~/Applications/ai-control.app +``` + +Or avoid the topic entirely and build from source (below) — self-built apps aren't quarantined. + +### Linux + +Releases carry a `.deb` and an AppImage. + +- **deb** — recommended. It also installs the GNOME Shell extension and the KWin script that provide the tray/popup integration on GNOME and KDE Wayland. +- **AppImage** — runs on desktops with a standard tray (KDE, XFCE, Cinnamon). On GNOME it refuses to start and points to the deb: the tray there needs the shell extension, which an installation-free AppImage cannot provide. + +Tray integration by desktop: GNOME via the bundled shell extension, KDE Wayland via StatusNotifierItem plus the bundled KWin script for popup placement, KDE X11/XFCE/Cinnamon via StatusNotifierItem directly. + +### Installer tests + +The installers are hand-tested per release. Current state: Ubuntu 26.04 (GNOME), Kubuntu (KDE Plasma, Wayland) and Linux Mint (Cinnamon) via deb — on Mint install through the graphical installer; macOS re-test of the current build is in progress. + +### Windows + +Follows soon. + ## Project layout ``` ├── index.html main UI (project/pool management) -├── terminal.html terminal window (xterm.js) +├── terminal.html terminal window (xterm.js) + docked text panel +├── panel.html detached text panel window +├── popup.html tray popup ├── src/ frontend: Vue 3 + TypeScript ├── src-tauri/ -│ ├── src/lib.rs Tauri commands: projects, pools, keychain, -│ │ OAuth login, tray menu, session watcher -│ ├── src/terminal.rs PTY sessions (portable-pty), terminal windows, -│ │ Dock icon, focusing running terminals +│ ├── src/lib.rs entry point, process roles +│ ├── src/app.rs Tauri wiring: tray, main/popup windows, watcher +│ ├── src/mcp.rs MCP stdio server (write_panel / archive_panel) +│ ├── src/terminal.rs PTY sessions (portable-pty), terminal windows +│ ├── src/platform/ macOS / Linux / Windows specifics │ └── icons/ app and tray icons +├── gnome-shell-extension/ tray/popup integration for GNOME +├── kwin-script/ popup placement for KDE Wayland ├── dev.sh development mode (tauri dev) -└── build.sh release build (.app bundle) +└── build.sh macOS release build (.app bundle + DMG) ``` -Two process roles from one binary: +Process roles from one binary: - **Main app** (`ai-control`) — tray, main window, management, watcher. - **Terminal process** (`ai-control --terminal `) — one window with its own PTY; launches Claude Code in the project directory with the assigned pool's `CLAUDE_CONFIG_DIR`. +- **MCP server** (`ai-control --mcp-panel`) — stdio server started by Claude Code itself; no GUI. Running projects are detected through their terminal processes (`pgrep` for `--terminal `), without a state file. @@ -57,28 +102,21 @@ Running projects are detected through their terminal processes (`pgrep` for `--t | Frontend | Vue 3, TypeScript, Vite | | Terminal | xterm.js, portable-pty | | macOS | objc2 / objc2-app-kit (Dock icon, window focus, tray) | -| Secrets | keyring (macOS Keychain) | - -## Installation - -Prebuilt DMGs are attached to the releases. The bundles are **not signed or notarized** — an Apple Developer ID costs €99/year, which this project doesn't have. If you'd like to change that: [Buy me a coffee](https://buymeacoffee.com/marcusH). macOS quarantines the unsigned download; after copying `ai-control.app` to `~/Applications`, clear it with: - -```sh -xattr -d com.apple.quarantine ~/Applications/ai-control.app -``` - -Or avoid the topic entirely and build from source (below) — self-built apps aren't quarantined. +| Linux | GTK 3 / WebKitGTK, ksni (StatusNotifierItem), zbus (D-Bus) | +| Secrets | keyring (macOS Keychain / Secret Service) | ## Development ```sh ./dev.sh # tauri dev with hot reload -./build.sh # release build; bundle lands in src-tauri/target/release/bundle/macos.noindex/ +./build.sh # macOS release build; bundle lands in src-tauri/target/release/bundle/macos.noindex/ ``` -`build.sh` moves the bundle into a `.noindex` directory and unregisters it from Launch Services so Spotlight only finds the installed copy. Install by copying `ai-control.app` to `~/Applications`. +On Linux, build with `npm run tauri build`; artifacts land under `src-tauri/target/release/bundle/` (`deb/`, `appimage/`). Build dependencies: `webkit2gtk-4.1`, `libgtk-3`, `libayatana-appindicator3`, `librsvg2`, `patchelf`. -Prerequisites: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` expect `CARGO_HOME`/`RUSTUP_HOME` under `~/tools/` — adjust the two exports in the scripts for a standard installation. +`build.sh` moves the macOS bundle into a `.noindex` directory and unregisters it from Launch Services so Spotlight only finds the installed copy. Install by copying `ai-control.app` to `~/Applications`. + +Prerequisites: Rust (stable), Node.js/npm. `dev.sh`/`build.sh` expect `CARGO_HOME`/`RUSTUP_HOME` under `~/tools/` — adjust the two exports in the scripts for a standard installation. ## Configuration layout @@ -91,22 +129,25 @@ Prerequisites: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` expect `CA / any path, mapped via the registry ├── .claude/ Claude Code project configuration -└── ai-control.json pool assignment + terminal settings +└── ai-control.json pool assignment, terminal settings, + panel archive directory (archiveDir) ``` ### settings.json | Key | Default | Meaning | |--------------------|----------|---------| -| `claudeCommand` | `claude` | Command launched in the project terminal (via `zsh -ic`, so your `.zshrc` PATH applies). | +| `claudeCommand` | `claude` | Command launched in the project terminal (via your login shell, `$SHELL -lc`, so your profile PATH applies). | | `syncOnSessionEnd` | `false` | After a session ends, commit and push the Git repository containing the project. | | `poolSyncDir` | unset | Directory to sync pool runtime data into (session transcripts, todos, prompt history — what `/resume` needs). When set, a pool's runtime files are replaced by symlinks into `/`, so sessions travel with however that directory is synced (git, Syncthing, …). Unset: everything stays local in the pool directory. | +| `terminalFontSize` | `13` | Font size of the built-in terminal (also adjustable from the terminal window). | +| `spellcheckLang` | unset | Default spell-check language of the text panel (per-text override in the panel). | ## Claude and Anthropic licensing -ai-control is pool management only — it does not change, extend, or circumvent Anthropic's licensing in any way. Using Claude Code through ai-control is subject to the same Anthropic terms as running it directly. +aICentral is pool management only — it does not change, extend, or circumvent Anthropic's licensing in any way. Using Claude Code through aICentral is subject to the same Anthropic terms as running it directly. -For that reason the app stores no OAuth tokens itself: with an OAuth pool, the first session of a project on that pool goes through the regular Anthropic login (`/login`, browser, against your subscription), run by Claude Code itself. Credentials live where Claude Code puts them — in the macOS keychain, one entry per pool. +For that reason the app stores no OAuth tokens itself: with an OAuth pool, the first session of a project on that pool goes through the regular Anthropic login (`/login`, browser, against your subscription), run by Claude Code itself. Credentials live where Claude Code puts them — in the macOS Keychain or the freedesktop Secret Service, one entry per pool. API keys go into the keychain as well (one entry per pool). Only when no keychain/keyring is available does the app offer — after an explicit confirmation — to store the key as a file in the pool directory (mode 0600). We strongly advise against that fallback: an API key on disk is readable by every process running as your user. Use the keychain. diff --git a/package-lock.json b/package-lock.json index 73acc0e..55fec5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@fontsource/jetbrains-mono": "^5.2.8", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2.5.4", "@xterm/addon-fit": "^0.11.0", @@ -717,6 +718,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-clipboard-manager": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz", + "integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", diff --git a/package.json b/package.json index 4ce78c8..283a0ca 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@fontsource/jetbrains-mono": "^5.2.8", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2.5.4", "@xterm/addon-fit": "^0.11.0", diff --git a/panel.html b/panel.html index 03dd768..ca83ef5 100644 --- a/panel.html +++ b/panel.html @@ -2,7 +2,7 @@ - Entwurf + Dokument +
+
+ + + + + + +
+ + + + +
- Entwurf - + Dokument +
- @@ -213,25 +383,32 @@ - - + - - - -
+ + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 48337f8..71b2e87 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,6 +19,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -43,12 +55,14 @@ dependencies = [ "objc2-foundation", "portable-pty", "rfd 0.15.4", + "rusqlite", "serde", "serde_json", "sha2", "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-log", "uuid", @@ -111,6 +125,27 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -660,6 +695,15 @@ dependencies = [ "vec_map", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "combine" version = "4.6.7" @@ -772,6 +816,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1031,7 +1081,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", @@ -1179,6 +1229,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1200,12 +1256,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1251,6 +1325,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1267,6 +1347,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1508,6 +1594,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1690,13 +1786,42 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", ] [[package]] @@ -1705,6 +1830,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1984,6 +2118,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "tiff", ] [[package]] @@ -2232,6 +2367,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2388,6 +2534,15 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2674,6 +2829,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "pango" version = "0.18.3" @@ -2734,6 +2899,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + [[package]] name = "phf" version = "0.13.1" @@ -3002,6 +3178,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -3274,6 +3456,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" version = "1.42.1" @@ -4077,6 +4273,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -4313,6 +4524,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.53" @@ -4615,6 +4840,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4764,6 +5000,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec_map" version = "0.8.2" @@ -4904,6 +5146,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -5006,6 +5318,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -5480,6 +5798,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" @@ -5560,6 +5896,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xml-rs" version = "0.8.28" @@ -5750,6 +6103,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.12.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 18dcf36..37b63f2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,10 +29,13 @@ tauri = { version = "2.11.3", features = ["macos-private-api", "tray-icon", "ima tauri-plugin-log = "2" tauri-plugin-autostart = "2" tauri-plugin-dialog = "2" +tauri-plugin-clipboard-manager = "2" base64 = "0.22" sha2 = "0.10" portable-pty = "0.9" uuid = { version = "1", features = ["v4"] } +# Volltext-Suche übers Panel-Archiv (SQLite FTS5, in-memory aus dem Baum gebaut). +rusqlite = { version = "0.32", features = ["bundled"] } keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0d6021c..9b61d3d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -18,6 +18,7 @@ "core:window:allow-hide", "core:window:allow-start-resize-dragging", "autostart:default", - "dialog:default" + "dialog:default", + "clipboard-manager:allow-write-text" ] } diff --git a/src-tauri/resources/panel-skill.md b/src-tauri/resources/panel-skill.md index 740ad2d..06ccce4 100644 --- a/src-tauri/resources/panel-skill.md +++ b/src-tauri/resources/panel-skill.md @@ -1,25 +1,35 @@ --- name: panel -description: Beim Entwerfen längerer Texte (ADR, E-Mail, Dokument, Spezifikation, Commit-Message, Textbaustein) das MCP-Tool write_panel nutzen, statt den Text als Fließtext in den Chat zu schreiben — dann ist er im ai-control-Panel mit Maus selektier- und kopierbar. Auch auf ausdrückliche Bitte nutzen ("ins Panel", "ins Panel schreiben"). +description: Beim Entwerfen längerer Texte (ADR, E-Mail, Dokument, Spezifikation, Commit-Message, Textbaustein) das MCP-Tool write_panel nutzen, statt den Text als Fließtext in den Chat zu schreiben; für eine bestehende Datei write_panel mit path aufrufen. Befehle, die der Nutzer ausführen soll, IMMER über write_commands als kopierbare Kacheln ausgeben statt als Codeblock im Chat. Will der Nutzer die Befehlsliste sehen ("zeig die Befehle", "zeige die Befehlsliste"), show_commands aufrufen. Auch auf ausdrückliche Bitte nutzen ("ins Panel", "ins Panel schreiben"). --- # Panel -Claude Code läuft im TUI; sichtbarer Text lässt sich dort nicht sauber selektieren und kopieren. Die ai-control-App zeigt Text in einem andockbaren Panel neben dem Terminal an — selektierbar, mit Copy-Button. Der Kanal dahin ist das MCP-Tool `write_panel` (Server `aicontrol`). +Claude Code läuft im TUI; sichtbarer Text lässt sich dort nicht sauber selektieren und kopieren. Die ai-control-App zeigt Inhalte in einem andockbaren Panel neben dem Terminal an — selektierbar, mit Copy-Buttons. Die Kanäle dahin sind die MCP-Tools `write_panel` und `write_commands` (Server `text-panel`). -## Wann +## Entwürfe — `write_panel` -- Automatisch, sobald ein längerer Fließtext oder ein Dokument entworfen wird (ADR, E-Mail, Spezifikation, Commit-Message, Textbaustein). -- Auf ausdrückliche Bitte, etwas ins Panel zu legen. +- Automatisch, sobald ein längerer Fließtext oder ein Dokument entworfen wird (ADR, E-Mail, Spezifikation, Commit-Message, Textbaustein); außerdem auf ausdrückliche Bitte, etwas ins Panel zu legen. +- Neuer Text: vollständigen Entwurf als Argument `text` (Markdown-Rohtext, das Panel rendert ihn). +- Bestehende Datei: **immer** `path` statt `text` übergeben — der Server liest die Datei selbst von der Platte, der Inhalt wird nicht abgetippt. +- Den Text nicht zusätzlich als Fließtext in den Chat schreiben — im Chat nur kurz bestätigen (z. B. „Entwurf im Panel."). +- Nicht für normale Chat-Antworten, kurze Bestätigungen oder Code im laufenden Dialog. -Nicht für normale Chat-Antworten, kurze Bestätigungen oder Code im laufenden Dialog. +## Befehle — `write_commands` -## Vorgehen +- IMMER nutzen, wenn ein oder mehrere Shell-Befehle für den Nutzer bestimmt sind (er soll sie ausführen) — statt sie als Codeblock in den Chat zu schreiben. +- Argument `commands`: Array in Ausführungsreihenfolge, je Eintrag `cmd` (exakt ausführbar) und optional `note` (Kurznotiz). +- Das Panel zeigt sie als Kacheln mit Copy-Button, angehängt an die Befehls-History der Session (flüchtig, startet mit jeder Session leer); im Chat nur kurz einordnen, den Befehl nicht doppelt ausgeben. +- Nicht für Befehle, die Claude selbst ausführt, oder für Code, der nur erklärt wird. -1. `write_panel` mit dem vollständigen Entwurf als Argument `text` aufrufen (Markdown-Rohtext, das Panel rendert ihn). Das Panel blendet sich beim Schreiben automatisch ein. -2. Den Text **nicht** zusätzlich als Fließtext in den Chat schreiben — im Chat nur kurz bestätigen (z. B. „Entwurf im Panel."). -3. Ist das Tool nicht verfügbar (Terminal außerhalb von ai-control), den Text normal im Chat ausgeben. +## Befehlsliste zeigen — `show_commands` + +- Nutzen, wenn der Nutzer die bisherige Befehlsliste sehen will („zeig die Befehle", „zeige die Befehlsliste"): schaltet das Panel auf die Kachel-Ansicht der History, ohne etwas anzuhängen. Keine Argumente. ## Panel leer öffnen Soll das Panel ohne konkreten Inhalt geöffnet werden („mach das Panel auf", „Panel öffnen"), `write_panel` mit dem neutralen Platzhalter `Panel` aufrufen — leerer Text blendet das Panel nicht ein. Keinen erklärenden, werbenden oder ausgedachten Einleitungstext erfinden. + +## Fallback + +Sind die Tools nicht verfügbar (Terminal außerhalb von ai-control), Text bzw. Befehle normal im Chat ausgeben. diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index a370345..c8901ff 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -277,6 +277,7 @@ pub(crate) fn main_builder() -> tauri::Builder { pub(crate) fn terminal_builder(project: String) -> tauri::Builder { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) .manage(terminal::Terminals::default()) .setup(move |app| { let cfg = terminal_config(&project)?; @@ -302,10 +303,17 @@ pub(crate) fn terminal_builder(project: String) -> tauri::Builder { terminal::term_write, terminal::term_resize, terminal::panel_read, + terminal::commands_read, + terminal::commands_delete, terminal::panel_set, + terminal::search_read, + terminal::search_run, + terminal::wiki_read, + terminal::wiki_open, terminal::open_panel_window, commands::panel_archive_dir_cmd, commands::panel_archive_cmd, + commands::archive_docs_cmd, commands::reveal_path_cmd, // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name. commands::list_projects, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d4b1cd7..1d9f869 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,9 @@ use std::time::{Duration, Instant}; -use crate::domain::archive::{archive_panel_content, project_archive_dir}; +use crate::domain::archive::{ + archive_panel_content, project_archive_home, require_archive_home, ArchiveMeta, +}; use crate::domain::credentials::keychain_service; use crate::domain::paths::Paths; use crate::domain::pool::{ @@ -333,18 +335,28 @@ pub(crate) fn usage_stats(days: u32) -> Result, String> { // ---------- Panel-Archiv ---------- -/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet, +/// Konfiguriertes Archiv-Home des Projekts (für den Panel-Button: entscheidet, /// ob der Ordner-Dialog nötig ist). #[tauri::command] pub(crate) fn panel_archive_dir_cmd(project: String) -> Option { - project_archive_dir(&project).map(|p| p.display().to_string()) + project_archive_home(&project).map(|p| p.display().to_string()) } /// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt -/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein. +/// das Archiv-Home zugleich; ohne `dir` muss es konfiguriert sein. #[tauri::command] pub(crate) fn panel_archive_cmd(project: String, dir: Option) -> Result { - archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string()) + archive_panel_content(&project, dir.as_deref(), &ArchiveMeta::default()) + .map(|p| p.display().to_string()) +} + +/// Dokumentliste des Archiv-Index (frisch gescannt) fürs Panel. +#[tauri::command] +pub(crate) fn archive_docs_cmd( + project: String, +) -> Result, String> { + let home = require_archive_home(&project)?; + crate::domain::archive_index::scan_archive(&home) } /// Zeigt einen Pfad im Dateimanager (nach dem Archivieren „im Finder zeigen"). diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs index 6c75669..2532fc1 100644 --- a/src-tauri/src/domain/archive.rs +++ b/src-tauri/src/domain/archive.rs @@ -11,18 +11,23 @@ use crate::domain::project::{ }; use crate::domain::registry::project_dir; -/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir), +/// Konfiguriertes Archiv-Home des Projekts (ai-control.json: archiveHome), /// Home-expandiert; None, wenn nicht gesetzt. -pub(crate) fn project_archive_dir(project: &str) -> Option { +pub(crate) fn project_archive_home(project: &str) -> Option { let paths = Paths::real(); - let dir = read_project_config_in(&paths, project).ok()?.archive_dir?; + let dir = read_project_config_in(&paths, project).ok()?.archive_home?; Some(expand_home(&paths, &dir)) } -/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in +/// Archiv-Home als Result — die gemeinsame Vorbedingung aller Archiv-Tools. +pub(crate) fn require_archive_home(project: &str) -> Result { + project_archive_home(project).ok_or_else(|| "kein Archiv-Ordner gesetzt".into()) +} + +/// Setzt das Archiv-Home: ai-control.json (~-relativ) und ein Eintrag in /// permissions.additionalDirectories + Edit der Projekt-settings.json, damit /// claude das Archiv später lesen/scannen darf. -pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> { +pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), String> { let paths = Paths::real(); let expanded = expand_home(&paths, dir); if !expanded.is_absolute() { @@ -39,7 +44,7 @@ pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), St 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)?; - cfg.archive_dir = Some(contracted.clone()); + cfg.archive_home = Some(contracted.clone()); write_project_config_in(&paths, project, &cfg)?; add_archive_permission(&paths, project, &contracted) } @@ -82,17 +87,33 @@ fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) } +/// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder. +#[derive(Default)] +pub(crate) struct ArchiveMeta { + /// Unterordner relativ zum Archiv-Home (wird angelegt). + pub(crate) folder: Option, + /// Einzeiler fürs Frontmatter. + pub(crate) description: Option, + /// Schlagwörter fürs Frontmatter. + pub(crate) tags: Vec, +} + /// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit -/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich +/// Frontmatter im Archiv-Home. `dir_override` setzt das Home zugleich /// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad. pub(crate) fn archive_panel_content( project: &str, dir_override: Option<&str>, + meta: &ArchiveMeta, ) -> Result { if let Some(d) = dir_override { - set_project_archive_dir(project, d)?; + set_project_archive_home(project, d)?; } - let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?; + let home = require_archive_home(project)?; + let dir = match &meta.folder { + Some(f) => home.join(check_folder(f)?), + None => home, + }; let text = fs::read_to_string(panel_file(project)).unwrap_or_default(); if text.trim().is_empty() { return Err("Panel ist leer — nichts zu archivieren".into()); @@ -106,15 +127,110 @@ pub(crate) fn archive_panel_content( let (stamp, iso) = utc_stamp(secs); let title = first_line(&text); let path = dir.join(format!("{stamp}-{}.md", slugify(&title))); - let doc = format!( - "---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n", - title.replace('"', "'"), - text.trim_end(), - ); + let doc = format!("{}{}\n", frontmatter(&title, project, &iso, meta), text.trim_end()); fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?; Ok(path) } +/// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root). +fn check_folder(folder: &str) -> Result<&std::path::Path, String> { + let p = std::path::Path::new(folder); + let normal = p + .components() + .all(|c| matches!(c, std::path::Component::Normal(_))); + if p.components().next().is_some() && normal { + Ok(p) + } else { + Err(format!("Unterordner muss ein relativer Pfad ohne '..' sein: {folder}")) + } +} + +/// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler +/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten — +/// Schreiber und Leser des Formats leben bewusst im selben Modul. +fn frontmatter(title: &str, project: &str, iso: &str, meta: &ArchiveMeta) -> String { + let mut fm = format!( + "---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n", + title.replace('"', "'"), + ); + if let Some(d) = &meta.description { + fm.push_str(&format!("description: \"{}\"\n", d.replace('"', "'"))); + } + if !meta.tags.is_empty() { + let quoted: Vec = + meta.tags.iter().map(|t| format!("\"{}\"", t.replace('"', "'"))).collect(); + fm.push_str(&format!("tags: [{}]\n", quoted.join(", "))); + } + fm.push_str("---\n\n"); + fm +} + +/// Minimaler Frontmatter-Parser für die selbst geschriebenen Dokumente: +/// `key: value`-Zeilen zwischen den beiden `---`-Markern, Anführungszeichen +/// um Werte werden entfernt. +pub(crate) fn parse_frontmatter(text: &str) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + let Some(rest) = text.strip_prefix("---\n") else { + return map; + }; + let Some(end) = rest.find("\n---") else { + return map; + }; + for line in rest[..end].lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + map.insert(key.trim().to_string(), unquote(value.trim()).to_string()); + } + map +} + +/// Dokument-Rumpf ohne den Frontmatter-Block; führende Leerzeilen entfernt. +pub(crate) fn strip_frontmatter(text: &str) -> &str { + let Some(rest) = text.strip_prefix("---\n") else { + return text; + }; + match rest.find("\n---\n") { + Some(end) => rest[end + 5..].trim_start_matches('\n'), + None => text, + } +} + +fn unquote(s: &str) -> &str { + s.strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(s) +} + +/// Inline-Liste `["a", "b"]` bzw. `[a, b]` in Einzel-Tags zerlegen. +pub(crate) fn parse_tag_list(raw: &str) -> Vec { + raw + .trim_start_matches('[') + .trim_end_matches(']') + .split(',') + .map(|t| unquote(t.trim()).to_string()) + .filter(|t| !t.is_empty()) + .collect() +} + +/// Datei-Stem ohne führenden `YYYY-MM-DD_HHMM-`-Zeitstempel — das Gegenstück +/// zum Dateinamen aus `utc_stamp` + `slugify`. +pub(crate) fn strip_stamp(stem: &str) -> &str { + let bytes = stem.as_bytes(); + let stamped = bytes.len() > 16 + && bytes[..16].iter().enumerate().all(|(i, b)| match i { + 4 | 7 => *b == b'-', + 10 => *b == b'_', + 15 => *b == b'-', + _ => b.is_ascii_digit(), + }); + if stamped { + &stem[16..] + } else { + stem + } +} + /// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile. fn first_line(text: &str) -> String { let mut fallback: Option<&str> = None; @@ -133,7 +249,7 @@ fn first_line(text: &str) -> String { } /// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen). -fn slugify(s: &str) -> String { +pub(crate) fn slugify(s: &str) -> String { let mut out = String::new(); for c in s.chars() { if c.is_ascii_alphanumeric() { @@ -200,6 +316,55 @@ mod tests { assert_eq!(slugify("###"), "entwurf"); } + #[test] + fn stamp_und_strip_roundtrip() { + let (stamp, _) = utc_stamp(20_645u64 * 86400); + assert_eq!(strip_stamp(&format!("{stamp}-adr-logging")), "adr-logging"); + assert_eq!(strip_stamp("adr-logging"), "adr-logging"); + assert_eq!(strip_stamp("2026-07-19-adr"), "2026-07-19-adr"); + } + + #[test] + fn frontmatter_und_parser_roundtrip() { + let meta = ArchiveMeta { + folder: None, + description: Some("Kurz".into()), + tags: vec!["adr".into(), "infra".into()], + }; + let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &meta); + let map = parse_frontmatter(&fm); + assert_eq!(map.get("title").map(String::as_str), Some("Titel")); + assert_eq!(map.get("description").map(String::as_str), Some("Kurz")); + assert_eq!(parse_tag_list(&map["tags"]), vec!["adr", "infra"]); + } + + #[test] + fn check_folder_relativ_ohne_punktpunkt() { + assert!(check_folder("konzepte/panel").is_ok()); + assert!(check_folder("../raus").is_err()); + assert!(check_folder("a/../b").is_err()); + assert!(check_folder("/absolut").is_err()); + assert!(check_folder("").is_err()); + } + + #[test] + fn frontmatter_mit_und_ohne_meta() { + let leer = ArchiveMeta::default(); + let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &leer); + assert!(fm.starts_with("---\ntitle: \"Titel\"\n")); + assert!(!fm.contains("description:")); + assert!(!fm.contains("tags:")); + + let voll = ArchiveMeta { + folder: None, + description: Some("Kurz \"zitiert\"".into()), + tags: vec!["archiv".into(), "wiki".into()], + }; + let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &voll); + assert!(fm.contains("description: \"Kurz 'zitiert'\"\n")); + assert!(fm.contains("tags: [\"archiv\", \"wiki\"]\n")); + } + #[test] fn first_line_ueberschrift_oder_erste_zeile() { assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel"); diff --git a/src-tauri/src/domain/archive_index.rs b/src-tauri/src/domain/archive_index.rs new file mode 100644 index 0000000..cb8da37 --- /dev/null +++ b/src-tauri/src/domain/archive_index.rs @@ -0,0 +1,376 @@ +//! Archiv-Index: reproduzierbare Sicht über den Archiv-Baum eines Projekts — +//! pro Markdown-Dokument Name, Frontmatter-Metadaten und Wikilinks; Backlinks +//! fallen beim Scan als Nebenprodukt ab. Der Index ist abgeleitete Information +//! und wird bei Bedarf frisch aus dem Baum gebaut (nichts davon wird gesynct). +//! Das Dateiformat (Frontmatter, Zeitstempel-Stem) definiert archive.rs. + +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use crate::domain::archive::{ + parse_frontmatter, parse_tag_list, slugify, strip_frontmatter, strip_stamp, +}; + +#[derive(serde::Serialize, Clone)] +pub(crate) struct Doc { + /// Pfad relativ zum Archiv-Home. + pub(crate) relpath: String, + /// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel. + pub(crate) name: String, + /// Frontmatter-Titel, sonst der Name. + pub(crate) title: String, + pub(crate) description: Option, + pub(crate) tags: Vec, + /// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel). + pub(crate) links: Vec, + /// Namen der Dokumente, die per Wikilink hierher zeigen. + pub(crate) backlinks: Vec, +} + +/// Scannt den Archiv-Baum rekursiv über alle Markdown-Dateien; versteckte +/// Einträge (Punkt-Präfix) bleiben außen vor. Reihenfolge: relpath sortiert; +/// Backlinks sind nach dem Scan gefüllt. +pub(crate) fn scan_archive(home: &Path) -> Result, String> { + Ok(scan_with_bodies(home)?.into_iter().map(|(doc, _)| doc).collect()) +} + +/// Wie `scan_archive`, liefert zu jedem Dokument den bereits gelesenen +/// Volltext mit — die Suche indexiert damit ohne zweite Lesung pro Datei. +pub(crate) fn scan_with_bodies(home: &Path) -> Result, String> { + let mut docs = Vec::new(); + walk(home, home, &mut docs)?; + docs.sort_by(|a, b| a.0.relpath.cmp(&b.0.relpath)); + fill_backlinks(&mut docs); + Ok(docs) +} + +/// Backlinks in einem Durchlauf: Slug → Doc-Index einmal aufbauen, jeden Link +/// genau einmal auflösen und zu Backlinks invertieren. +fn fill_backlinks(docs: &mut [(Doc, String)]) { + let mut lookup: HashMap = HashMap::new(); + for (i, (doc, _)) in docs.iter().enumerate() { + let stem = Path::new(&doc.relpath).file_stem().unwrap_or_default().to_string_lossy(); + for key in [slugify(&doc.name), slugify(&doc.title), slugify(&stem)] { + lookup.entry(key).or_insert(i); + } + } + let mut back: Vec> = vec![Vec::new(); docs.len()]; + for (i, (doc, _)) in docs.iter().enumerate() { + let mut targets: std::collections::BTreeSet = docs[i] + .0 + .links + .iter() + .filter_map(|l| lookup.get(&slugify(l)).copied()) + .collect(); + targets.remove(&i); + for t in targets { + back[t].push(doc.name.clone()); + } + } + for ((doc, _), b) in docs.iter_mut().zip(back) { + doc.backlinks = b; + } +} + +fn walk(home: &Path, dir: &Path, docs: &mut Vec<(Doc, String)>) -> Result<(), String> { + let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?; + let path = entry.path(); + let file_name = entry.file_name(); + let fname = file_name.to_string_lossy(); + if fname.starts_with('.') { + continue; + } + if path.is_dir() { + walk(home, &path, docs)?; + } else if fname.ends_with(".md") { + docs.push(read_doc(home, &path)?); + } + } + Ok(()) +} + +fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> { + let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + let name = strip_stamp(&stem).to_string(); + let fm = parse_frontmatter(&text); + let relpath = path + .strip_prefix(home) + .map_err(|e| format!("{}: {e}", path.display()))? + .display() + .to_string(); + let doc = Doc { + relpath, + title: fm.get("title").unwrap_or(&name).clone(), + description: fm.get("description").cloned(), + tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(), + links: wikilinks(&text), + backlinks: Vec::new(), + name, + }; + Ok((doc, text)) +} + +/// Slug-Vergleich eines Wikilink-Ziels gegen Name, Titel und Datei-Stem. +fn matches(doc: &Doc, want: &str) -> bool { + slugify(&doc.name) == want + || slugify(&doc.title) == want + || Path::new(&doc.relpath) + .file_stem() + .is_some_and(|s| slugify(&s.to_string_lossy()) == want) +} + +/// Übersichts- bzw. Schlagwort-Seite als strukturierte Wiki-Daten: Dokumente +/// nach Ordnern gruppiert (neueste zuerst), Schlagwort-Leiste mit Zählern. +/// Das Panel rendert daraus die Wiki-Ansicht; `kind` unterscheidet im +/// Wiki-Puffer Seite und Dokument. +#[derive(serde::Serialize)] +pub(crate) struct WikiPage { + pub(crate) kind: &'static str, + pub(crate) home: String, + pub(crate) tag: Option, + pub(crate) total: usize, + pub(crate) tags: Vec, + pub(crate) folders: Vec, +} + +#[derive(serde::Serialize)] +pub(crate) struct TagCount { + pub(crate) name: String, + pub(crate) count: usize, +} + +#[derive(serde::Serialize)] +pub(crate) struct WikiFolder { + /// Ordner relativ zum Archiv-Home; leer für die Wurzel. + pub(crate) name: String, + pub(crate) docs: Vec, +} + +#[derive(serde::Serialize)] +pub(crate) struct WikiDocEntry { + pub(crate) name: String, + pub(crate) title: String, + pub(crate) description: Option, + pub(crate) tags: Vec, + /// Archivierungsdatum aus dem Zeitstempel-Stem (`YYYY-MM-DD`). + pub(crate) date: Option, +} + +pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result { + let docs = scan_archive(home)?; + let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); + for doc in &docs { + for t in &doc.tags { + *counts.entry(t).or_default() += 1; + } + } + let selected: Vec<&Doc> = match tag { + Some(t) => docs.iter().filter(|d| d.tags.iter().any(|x| x == t)).collect(), + None => docs.iter().collect(), + }; + let mut folders: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for doc in &selected { + let folder = Path::new(&doc.relpath) + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + folders.entry(folder).or_default().push(doc); + } + Ok(WikiPage { + kind: "page", + home: home.display().to_string(), + tag: tag.map(str::to_string), + total: selected.len(), + tags: counts + .into_iter() + .map(|(name, count)| TagCount { name: name.to_string(), count }) + .collect(), + folders: folders + .into_iter() + .map(|(name, mut list)| { + // Zeitstempel-Stems sortieren chronologisch — absteigend = neueste oben. + list.sort_by(|a, b| b.relpath.cmp(&a.relpath)); + WikiFolder { name, docs: list.into_iter().map(doc_entry).collect() } + }) + .collect(), + }) +} + +fn doc_entry(doc: &Doc) -> WikiDocEntry { + let stem = Path::new(&doc.relpath) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let date = (stem != doc.name).then(|| stem[..10].to_string()); + WikiDocEntry { + name: doc.name.clone(), + title: doc.title.clone(), + description: doc.description.clone(), + tags: doc.tags.clone(), + date, + } +} + +/// Ein Archiv-Dokument als Wiki-Ansicht: Markdown-Rumpf ohne Frontmatter plus +/// Metadaten und Backlinks aus dem Scan. +#[derive(serde::Serialize)] +pub(crate) struct WikiDocPage { + pub(crate) kind: &'static str, + pub(crate) home: String, + pub(crate) relpath: String, + pub(crate) name: String, + pub(crate) title: String, + pub(crate) tags: Vec, + pub(crate) backlinks: Vec, + pub(crate) markdown: String, +} + +pub(crate) fn wiki_doc(home: &Path, target: &str) -> Result { + let pairs = scan_with_bodies(home)?; + let want = slugify(target); + let (doc, body) = pairs + .iter() + .find(|(d, _)| matches(d, &want)) + .ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))?; + Ok(WikiDocPage { + kind: "doc", + home: home.display().to_string(), + relpath: doc.relpath.clone(), + name: doc.name.clone(), + title: doc.title.clone(), + tags: doc.tags.clone(), + backlinks: doc.backlinks.clone(), + markdown: strip_frontmatter(body).to_string(), + }) +} + +/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei +/// `[[ziel|label]]` zählt nur das Ziel. +fn wikilinks(text: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find("[[") { + rest = &rest[start + 2..]; + let Some(end) = rest.find("]]") else { + break; + }; + let inner = &rest[..end]; + let target = inner.split('|').next().unwrap_or(inner).trim(); + if !target.is_empty() { + out.push(target.to_string()); + } + rest = &rest[end + 2..]; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::testutil::tmp_paths; + use std::path::PathBuf; + + fn write(home: &Path, rel: &str, content: &str) { + let path = home.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + fn archiv() -> PathBuf { + let home = tmp_paths().home.join("archiv"); + fs::create_dir_all(&home).unwrap(); + write( + &home, + "2026-07-19_1000-adr-logging.md", + "---\ntitle: \"ADR Logging\"\ndescription: \"Logging vereinheitlichen\"\ntags: [\"adr\", \"infra\"]\n---\n\nSiehe [[notiz-deploy|die Deploy-Notiz]].\n", + ); + write( + &home, + "konzepte/2026-07-19_1005-notiz-deploy.md", + "---\ntitle: \"Notiz Deploy\"\n---\n\nText ohne Links.\n", + ); + write(&home, ".versteckt/ignoriert.md", "unsichtbar"); + home + } + + #[test] + fn scan_liest_baum_frontmatter_und_links() { + let home = archiv(); + let docs = scan_archive(&home).unwrap(); + assert_eq!(docs.len(), 2); + let adr = &docs[0]; + assert_eq!(adr.name, "adr-logging"); + assert_eq!(adr.title, "ADR Logging"); + assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen")); + assert_eq!(adr.tags, vec!["adr", "infra"]); + assert_eq!(adr.links, vec!["notiz-deploy"]); + assert_eq!(docs[1].relpath, "konzepte/2026-07-19_1005-notiz-deploy.md"); + } + + #[test] + fn backlinks_aus_wikilinks() { + let home = archiv(); + let docs = scan_archive(&home).unwrap(); + let deploy = docs.iter().find(|d| d.name == "notiz-deploy").unwrap(); + assert_eq!(deploy.backlinks, vec!["adr-logging"]); + let adr = docs.iter().find(|d| d.name == "adr-logging").unwrap(); + assert!(adr.backlinks.is_empty()); + } + + #[test] + fn startseite_gruppiert_und_zaehlt() { + let home = archiv(); + let page = archive_page(&home, None).unwrap(); + assert_eq!(page.kind, "page"); + assert_eq!(page.tag, None); + assert_eq!(page.total, 2); + let tags: Vec<(&str, usize)> = + page.tags.iter().map(|t| (t.name.as_str(), t.count)).collect(); + assert_eq!(tags, vec![("adr", 1), ("infra", 1)]); + assert_eq!(page.folders.len(), 2); + assert_eq!(page.folders[0].name, ""); + let adr = &page.folders[0].docs[0]; + assert_eq!(adr.name, "adr-logging"); + assert_eq!(adr.title, "ADR Logging"); + assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen")); + assert_eq!(adr.date.as_deref(), Some("2026-07-19")); + assert_eq!(page.folders[1].name, "konzepte"); + assert_eq!(page.folders[1].docs[0].name, "notiz-deploy"); + } + + #[test] + fn tag_seite_filtert() { + let home = archiv(); + let page = archive_page(&home, Some("adr")).unwrap(); + assert_eq!(page.tag.as_deref(), Some("adr")); + assert_eq!(page.total, 1); + assert_eq!(page.folders.len(), 1); + assert_eq!(page.folders[0].docs[0].name, "adr-logging"); + // Die Schlagwort-Leiste bleibt vollständig — sie ist die Navigation. + assert_eq!(page.tags.len(), 2); + } + + #[test] + fn wiki_doc_mit_rumpf_und_backlinks() { + let home = archiv(); + // Auflösung über Titel; Name und Stem gehen über dieselben Slug-Vergleiche. + let doc = wiki_doc(&home, "Notiz Deploy").unwrap(); + assert_eq!(doc.kind, "doc"); + assert_eq!(doc.name, "notiz-deploy"); + assert_eq!( + wiki_doc(&home, "2026-07-19_1000-adr-logging").unwrap().name, + "adr-logging" + ); + assert_eq!(doc.title, "Notiz Deploy"); + assert_eq!(doc.relpath, "konzepte/2026-07-19_1005-notiz-deploy.md"); + assert_eq!(doc.backlinks, vec!["adr-logging"]); + assert_eq!(doc.markdown, "Text ohne Links.\n"); + assert!(wiki_doc(&home, "fehlt").is_err()); + } +} diff --git a/src-tauri/src/domain/archive_search.rs b/src-tauri/src/domain/archive_search.rs new file mode 100644 index 0000000..3dce00e --- /dev/null +++ b/src-tauri/src/domain/archive_search.rs @@ -0,0 +1,139 @@ +//! Volltext-Suche übers Panel-Archiv: SQLite-FTS5-Index, bei jeder Anfrage +//! frisch in-memory aus dem Archiv-Baum gebaut. Bei den Archiv-Größen dieser +//! App ist der Aufbau Millisekundensache; damit gibt es keinen persistierten +//! Index, keine Staleness und nichts, was gesynct werden könnte. Die +//! Tool-Schnittstelle bleibt engine-unabhängig. + +use std::path::Path; + +use rusqlite::Connection; + +use crate::domain::archive_index::scan_with_bodies; + +#[derive(serde::Serialize)] +pub(crate) struct Hit { + /// Pfad relativ zum Archiv-Home. + pub(crate) relpath: String, + pub(crate) title: String, + /// Textausschnitt um die Fundstelle, Treffer in `**…**`. + pub(crate) snippet: String, +} + +/// Durchsucht das Archiv unter `home`. `query` ist FTS5-Syntax (Wörter, +/// "Phrasen", Präfix*); `tag` engt auf ein Schlagwort ein. Treffer nach +/// BM25-Rang, höchstens `limit`. +pub(crate) fn search( + home: &Path, + query: &str, + tag: Option<&str>, + limit: usize, +) -> Result, String> { + let conn = build_index(home)?; + let expr = match_expr(query, tag)?; + let mut stmt = conn + .prepare( + "SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \ + FROM docs WHERE docs MATCH ?1 ORDER BY rank LIMIT ?2", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params![expr, limit as i64], |row| { + Ok(Hit { relpath: row.get(0)?, title: row.get(1)?, snippet: row.get(2)? }) + }) + .map_err(|e| format!("Suchausdruck „{query}“: {e}"))?; + rows.collect::, _>>().map_err(|e| e.to_string()) +} + +/// MATCH-Ausdruck aus Query und optionalem Tag-Filter. +fn match_expr(query: &str, tag: Option<&str>) -> Result { + let q = query.trim(); + let t = tag.map(|t| format!("tags:\"{}\"", t.replace('"', ""))); + match (q.is_empty(), t) { + (false, Some(t)) => Ok(format!("({q}) AND {t}")), + (false, None) => Ok(q.to_string()), + (true, Some(t)) => Ok(t), + (true, None) => Err("leere Suchanfrage".into()), + } +} + +/// Baut den FTS5-Index in-memory aus dem Archiv-Baum. +fn build_index(home: &Path) -> Result { + let conn = Connection::open_in_memory().map_err(|e| e.to_string())?; + conn + .execute_batch( + "CREATE VIRTUAL TABLE docs USING fts5(relpath UNINDEXED, name, title, description, tags, body)", + ) + .map_err(|e| e.to_string())?; + let docs = scan_with_bodies(home)?; + let mut insert = conn + .prepare("INSERT INTO docs (relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6)") + .map_err(|e| e.to_string())?; + for (doc, body) in &docs { + insert + .execute(rusqlite::params![ + doc.relpath, + doc.name, + doc.title, + doc.description.as_deref().unwrap_or(""), + doc.tags.join(" "), + body, + ]) + .map_err(|e| e.to_string())?; + } + drop(insert); + Ok(conn) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::testutil::tmp_paths; + use std::fs; + + fn archiv() -> std::path::PathBuf { + let home = tmp_paths().home.join("archiv"); + fs::create_dir_all(home.join("konzepte")).unwrap(); + fs::write( + home.join("2026-07-19_1000-adr-logging.md"), + "---\ntitle: \"ADR Logging\"\ntags: [\"adr\", \"infra\"]\n---\n\nStrukturiertes Logging mit tracing vereinheitlichen.\n", + ) + .unwrap(); + fs::write( + home.join("konzepte/2026-07-19_1005-notiz-deploy.md"), + "---\ntitle: \"Notiz Deploy\"\ntags: [\"infra\"]\n---\n\nDeploy braucht lsregister auf macOS.\n", + ) + .unwrap(); + home + } + + #[test] + fn findet_nach_inhaltswort() { + let home = archiv(); + let hits = search(&home, "tracing", None, 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, "ADR Logging"); + assert!(hits[0].snippet.contains("**tracing**")); + } + + #[test] + fn tag_filter_engt_ein() { + let home = archiv(); + assert_eq!(search(&home, "", Some("infra"), 10).unwrap().len(), 2); + assert_eq!(search(&home, "", Some("adr"), 10).unwrap().len(), 1); + let hits = search(&home, "deploy", Some("adr"), 10).unwrap(); + assert!(hits.is_empty()); + } + + #[test] + fn phrase_und_praefix() { + let home = archiv(); + assert_eq!(search(&home, "\"Strukturiertes Logging\"", None, 10).unwrap().len(), 1); + assert_eq!(search(&home, "lsregist*", None, 10).unwrap().len(), 1); + } + + #[test] + fn leere_anfrage_scheitert() { + let home = archiv(); + assert!(search(&home, " ", None, 10).is_err()); + } +} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index a5962da..9ce993a 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -3,6 +3,8 @@ //! laufen ausschließlich über crate::platform. pub(crate) mod archive; +pub(crate) mod archive_index; +pub(crate) mod archive_search; pub(crate) mod credentials; pub(crate) mod paths; pub(crate) mod pool; diff --git a/src-tauri/src/domain/paths.rs b/src-tauri/src/domain/paths.rs index ae46d18..85bc677 100644 --- a/src-tauri/src/domain/paths.rs +++ b/src-tauri/src/domain/paths.rs @@ -56,6 +56,33 @@ pub(crate) fn panel_file(project: &str) -> PathBuf { Paths::real().panels_dir().join(format!("{project}.md")) } +/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim +/// Session-Start geleert). Der Pfad landet als AI_CONTROL_COMMANDS in der +/// PTY-Umgebung. +pub(crate) fn commands_file(project: &str) -> PathBuf { + Paths::real() + .panels_dir() + .join(format!("{project}.commands.jsonl")) +} + +/// Suchtreffer-Datei eines Projekts (JSON, letzter search_archive-Aufruf — +/// flüchtig, wird beim Session-Start geleert). Der Pfad landet als +/// AI_CONTROL_SEARCH in der PTY-Umgebung. +pub(crate) fn search_file(project: &str) -> PathBuf { + Paths::real() + .panels_dir() + .join(format!("{project}.search.json")) +} + +/// Wiki-Puffer eines Projekts (JSON, jeweils letzte Wiki-Seite bzw. letztes +/// geöffnetes Dokument — flüchtig, wird beim Session-Start geleert). Der Pfad +/// landet als AI_CONTROL_WIKI in der PTY-Umgebung. +pub(crate) fn wiki_file(project: &str) -> PathBuf { + Paths::real() + .panels_dir() + .join(format!("{project}.wiki.json")) +} + /// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert. pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf { if p == "~" { diff --git a/src-tauri/src/domain/pool.rs b/src-tauri/src/domain/pool.rs index 5dd625c..12e0029 100644 --- a/src-tauri/src/domain/pool.rs +++ b/src-tauri/src/domain/pool.rs @@ -151,7 +151,7 @@ pub(crate) fn init_pool_config( let mut settings = serde_json::json!({ "promptSuggestionEnabled": false, "awaySummaryEnabled": false, - "permissions": { "allow": [PANEL_WRITE_PERMISSION] }, + "permissions": { "allow": PANEL_PERMISSIONS }, }); let base = settings.as_object_mut().unwrap(); if let Some(obj) = extra.as_object() { @@ -180,9 +180,12 @@ const PANEL_SKILL: &str = include_str!("../../resources/panel-skill.md"); /// („text panel"). Bestimmt den Tool-Namespace `mcp____`. const PANEL_MCP_SERVER: &str = "text-panel"; -/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage -/// läuft. Namensschema: `mcp____`. -const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel"; +/// Freigaben für die Panel-MCP-Tools — damit die Aufrufe ohne Rückfrage +/// laufen. Namensschema: `mcp____`. +const PANEL_PERMISSIONS: [&str; 2] = [ + "mcp__text-panel__write_panel", + "mcp__text-panel__write_commands", +]; /// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine /// evtl. ältere, tee-basierte Fassung). @@ -223,13 +226,14 @@ fn ensure_panel_permission(pool_dir: &std::path::Path) { else { return; }; - let before = allow.len(); + let before = allow.clone(); allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s))); - let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION)); - if !has { - allow.push(serde_json::json!(PANEL_WRITE_PERMISSION)); + for perm in PANEL_PERMISSIONS { + if !allow.iter().any(|e| e.as_str() == Some(perm)) { + allow.push(serde_json::json!(perm)); + } } - if has && allow.len() == before { + if *allow == before { return; // nichts geändert } if let Ok(out) = serde_json::to_string_pretty(&v) { diff --git a/src-tauri/src/domain/project.rs b/src-tauri/src/domain/project.rs index 39ad1f4..5319573 100644 --- a/src-tauri/src/domain/project.rs +++ b/src-tauri/src/domain/project.rs @@ -29,13 +29,16 @@ pub(crate) struct ProjectConfig { pub(crate) pool: Option, #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")] pub(crate) terminal: TerminalConfig, - /// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert). + /// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen + /// (~-relativ gespeichert). Liest den früheren Key `archiveDir` weiterhin ein, + /// geschrieben wird nur noch `archiveHome`. #[serde( default, - rename = "archiveDir", + rename = "archiveHome", + alias = "archiveDir", skip_serializing_if = "Option::is_none" )] - pub(crate) archive_dir: Option, + pub(crate) archive_home: Option, } #[derive(Serialize, Deserialize, Default, Clone)] @@ -223,7 +226,7 @@ pub(crate) fn create_project_full_in( reg.insert(name.to_string(), dir.clone()); save_registry(paths, ®)?; - let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None }; + let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_home: None }; if cfg.pool.is_some() || !cfg.terminal.is_empty() { write_project_config_in(paths, name, &cfg)?; } @@ -431,7 +434,7 @@ pub(crate) fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result pub(crate) fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> { let mut cfg = read_project_config_in(paths, project)?; cfg.pool = None; - if cfg.terminal.is_empty() && cfg.archive_dir.is_none() { + if cfg.terminal.is_empty() && cfg.archive_home.is_none() { let cfg_path = project_config_path(paths, project)?; return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display())); } diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 52fe9ef..78514eb 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -66,35 +66,142 @@ fn handle(method: &str, req: &Value) -> Option { Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \ ab, wo er mit der Maus selektierbar und über einen Button kopierbar \ ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \ - aufrufen und im Chat nur kurz bestätigen.", + aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \ + IMMER `path` statt `text` übergeben — der Server liest die Datei \ + selbst von der Platte, ohne dass ihr Inhalt generiert werden muss.", "inputSchema": { "type": "object", "properties": { "text": { "type": "string", "description": "Vollständiger Entwurf als Markdown-Rohtext.", + }, + "path": { + "type": "string", + "description": + "Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt ins \ + Panel geladen wird. Genau eines von beiden angeben.", } }, - "required": ["text"], }, }, + { + "name": "write_commands", + "description": + "Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \ + Kacheln im ai-control-Panel. IMMER nutzen, wenn ein Befehl für den \ + Nutzer bestimmt ist — statt ihn als Codeblock in den Chat zu \ + schreiben; im Chat nur kurz einordnen. Die Befehle werden an die \ + Befehls-History der Session angehängt (flüchtig, startet mit \ + jeder Session leer).", + "inputSchema": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "description": "Befehle in Ausführungsreihenfolge.", + "items": { + "type": "object", + "properties": { + "cmd": { + "type": "string", + "description": "Der Befehl, exakt so ausführbar.", + }, + "note": { + "type": "string", + "description": "Optionale Kurznotiz, was der Befehl tut.", + } + }, + "required": ["cmd"], + }, + } + }, + "required": ["commands"], + }, + }, + { + "name": "show_commands", + "description": + "Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \ + allen bisher ausgegebenen Befehlen), ohne etwas anzuhängen. Nutzen, \ + wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \ + „zeige die Befehlsliste“).", + "inputSchema": { "type": "object", "properties": {} }, + }, { "name": "archive_panel", "description": "Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \ - Markdown-Datei im Archiv-Ordner des Projekts. Auf Wunsch nutzen \ - (Nutzer sagt etwa „archiviere das“). Ist kein Ordner konfiguriert, \ - den Zielpfad im \ - Argument `dir` mitgeben (der Nutzer nennt ihn); ohne `dir` und ohne \ - konfigurierten Ordner meldet das Tool das zurück.", + Markdown-Datei im Archiv-Home des Projekts. Auf Wunsch nutzen \ + (Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \ + `description` und `tags` mitgeben — einmalige Kuratierung im Moment \ + des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \ + konfiguriert, den Zielpfad im Argument `dir` mitgeben (der Nutzer \ + nennt ihn); ohne `dir` und ohne konfiguriertes Home meldet das Tool \ + das zurück.", "inputSchema": { "type": "object", "properties": { "dir": { "type": "string", "description": - "Optionaler Archiv-Ordner. Wird gesetzt und für künftige \ - Archivierungen gemerkt.", + "Optionales Archiv-Home (absoluter Pfad). Wird gesetzt und für \ + künftige Archivierungen gemerkt.", + }, + "folder": { + "type": "string", + "description": + "Optionaler Unterordner im Archiv-Home, relativ (z. B. \ + `konzepte/panel`). Wird angelegt.", + }, + "description": { + "type": "string", + "description": "Einzeiler zum Inhalt fürs Frontmatter.", + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Schlagwörter fürs Frontmatter (kurze Slugs).", + } + }, + }, + }, + { + "name": "show_archive", + "description": + "Zeigt die Archiv-Übersicht des Projekts als Wiki-Seite im Panel: \ + Dokumente nach Ordnern gruppiert, mit Beschreibungen und \ + klickbaren Schlagwort-Links. Mit `tag` stattdessen die Seite eines \ + Schlagworts. Nutzen, wenn der Nutzer das Archiv sehen will \ + („zeig das Archiv“).", + "inputSchema": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "Optional: Seite dieses Schlagworts statt der Übersicht.", + } + }, + }, + }, + { + "name": "search_archive", + "description": + "Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \ + Wörter, \"Phrasen\", Präfix*). Die Treffer erscheinen als Kacheln \ + im Panel; das Tool liefert sie zusätzlich mit Pfad und Snippet \ + zurück. Nutzen, wenn der Nutzer im Archiv suchen will („such im \ + Archiv nach …“).", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Suchanfrage (FTS5-Syntax).", + }, + "tag": { + "type": "string", + "description": "Optional: auf ein Schlagwort einengen.", } }, }, @@ -110,44 +217,198 @@ fn handle(method: &str, req: &Value) -> Option { fn call_tool(req: &Value) -> Value { match req["params"]["name"].as_str().unwrap_or("") { "write_panel" => call_write(req), + "write_commands" => call_write_commands(req), + "show_commands" => call_show_commands(), "archive_panel" => call_archive(req), - other => json!({ - "content": [{ "type": "text", "text": format!("Unbekanntes Tool: {other}") }], - "isError": true, - }), + "search_archive" => call_search(req), + "show_archive" => call_show_archive(req), + other => err(format!("Unbekanntes Tool: {other}")), } } +/// Erfolgs-Antwort eines Tool-Aufrufs (ein Text-Content). +fn ok(text: String) -> Value { + json!({ "content": [{ "type": "text", "text": text }] }) +} + +/// Fehler-Antwort eines Tool-Aufrufs. +fn err(text: String) -> Value { + json!({ "content": [{ "type": "text", "text": text }], "isError": true }) +} + +/// Pfad aus der PTY-Umgebung; fehlt die Variable, läuft das Terminal +/// außerhalb von ai-control. +fn env_path(var: &str) -> Result { + std::env::var(var) + .map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control)."))) +} + +/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL. +fn write_panel_text(text: &str) -> Result<(), Value> { + let path = env_path("AI_CONTROL_PANEL")?; + std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}"))) +} + fn call_write(req: &Value) -> Value { - let text = req["params"]["arguments"]["text"].as_str().unwrap_or(""); - match std::env::var("AI_CONTROL_PANEL") { - Ok(path) => match std::fs::write(&path, text) { - Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }), - Err(e) => json!({ - "content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }], - "isError": true, - }), + let args = &req["params"]["arguments"]; + // `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne + // dass das Modell den Inhalt Token für Token als `text` generieren muss. + let (text, ok_msg) = match args["path"].as_str() { + Some(src) => match std::fs::read_to_string(src) { + Ok(content) => (content, "Datei ins Panel geladen."), + Err(e) => return err(format!("Datei {src} nicht lesbar: {e}")), }, - Err(_) => json!({ - "content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }], - "isError": true, - }), + None => ( + args["text"].as_str().unwrap_or("").to_string(), + "Entwurf ins Panel geschrieben.", + ), + }; + match write_panel_text(&text) { + Ok(()) => ok(ok_msg.into()), + Err(e) => e, + } +} + +/// Merkt, ob dieser Server-Prozess (= eine claude-Session) schon einen +/// Session-Marker in die History geschrieben hat. +static SESSION_MARKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +fn call_write_commands(req: &Value) -> Value { + let commands = req["params"]["arguments"]["commands"].clone(); + let count = commands.as_array().map(Vec::len).unwrap_or(0); + let path = match env_path("AI_CONTROL_COMMANDS") { + Ok(path) => path, + Err(e) => return e, + }; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Ein Record pro Aufruf; der erste Aufruf der Session bekommt einen + // Marker-Record davor (Session-Trenner in der Kachel-Ansicht). + let mut out = String::new(); + if !SESSION_MARKED.swap(true, std::sync::atomic::Ordering::SeqCst) { + out.push_str(&json!({ "ts": ts, "session": true }).to_string()); + out.push('\n'); + } + out.push_str(&json!({ "ts": ts, "commands": commands }).to_string()); + out.push('\n'); + let res = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut f| std::io::Write::write_all(&mut f, out.as_bytes())); + match res { + Ok(()) => ok(format!("{count} Befehl(e) als Kacheln im Panel abgelegt.")), + Err(e) => err(format!("Command-History nicht schreibbar: {e}")), + } +} + +/// Öffnet die Kachel-Ansicht, ohne der History etwas hinzuzufügen: mtime der +/// History-Datei anfassen genügt — der Watcher im Terminal-Prozess meldet sie +/// als `commands-update`, das Panel schaltet auf „Befehle“ um. +fn call_show_commands() -> Value { + let path = match env_path("AI_CONTROL_COMMANDS") { + Ok(path) => path, + Err(e) => return e, + }; + let res = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|f| f.set_modified(std::time::SystemTime::now())); + match res { + Ok(()) => ok("Befehls-History im Panel geöffnet.".into()), + Err(e) => err(format!("Command-History nicht erreichbar: {e}")), } } fn call_archive(req: &Value) -> Value { let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); - let dir = req["params"]["arguments"]["dir"].as_str(); - match crate::domain::archive::archive_panel_content(&project, dir) { - Ok(path) => json!({ - "content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }], - }), - Err(e) => json!({ - "content": [{ - "type": "text", - "text": format!("Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."), - }], - "isError": true, - }), + let args = &req["params"]["arguments"]; + let dir = args["dir"].as_str(); + let meta = crate::domain::archive::ArchiveMeta { + folder: args["folder"].as_str().map(str::to_string), + description: args["description"].as_str().map(str::to_string), + tags: args["tags"] + .as_array() + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(), + }; + match crate::domain::archive::archive_panel_content(&project, dir, &meta) { + Ok(path) => ok(format!("Archiviert: {}", path.display())), + Err(e) => err(format!( + "Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen." + )), } } + +/// Archiv-Übersicht bzw. Schlagwort-Seite generieren und in den Wiki-Puffer +/// schreiben — der Watcher zieht sie als Wiki-Ansicht ins Panel. +fn call_show_archive(req: &Value) -> Value { + let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); + let home = match crate::domain::archive::require_archive_home(&project) { + Ok(home) => home, + Err(e) => return err(e), + }; + let tag = req["params"]["arguments"]["tag"].as_str(); + let page = match crate::domain::archive_index::archive_page(&home, tag) { + Ok(page) => page, + Err(e) => return err(e), + }; + let path = match env_path("AI_CONTROL_WIKI") { + Ok(path) => path, + Err(e) => return e, + }; + let json = match serde_json::to_string(&page) { + Ok(json) => json, + Err(e) => return err(e.to_string()), + }; + match std::fs::write(&path, json) { + Ok(()) => ok(match tag { + Some(t) => format!("Schlagwort-Seite #{t} im Panel."), + None => "Archiv-Übersicht im Panel.".to_string(), + }), + Err(e) => err(format!("Wiki-Datei nicht schreibbar: {e}")), + } +} + +/// Volltext-Suche übers Archiv: Treffer als JSON in die Suchtreffer-Datei +/// (der Watcher zieht sie als Kacheln ins Panel) und als Text zurück an claude. +fn call_search(req: &Value) -> Value { + let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); + let home = match crate::domain::archive::require_archive_home(&project) { + Ok(home) => home, + Err(e) => return err(e), + }; + let args = &req["params"]["arguments"]; + let query = args["query"].as_str().unwrap_or(""); + let tag = args["tag"].as_str(); + let hits = match crate::domain::archive_search::search(&home, query, tag, 20) { + Ok(hits) => hits, + Err(e) => return err(format!("Suche fehlgeschlagen: {e}")), + }; + let search_path = match env_path("AI_CONTROL_SEARCH") { + Ok(path) => path, + Err(e) => return e, + }; + let payload = json!({ + "query": query, + "tag": tag, + "home": home.display().to_string(), + "hits": hits, + }); + if let Err(e) = std::fs::write(&search_path, payload.to_string()) { + return err(format!("Suchtreffer-Datei nicht schreibbar: {e}")); + } + let list: Vec = hits + .iter() + .map(|h| format!("- {} — {} ({})", h.relpath, h.title, h.snippet)) + .collect(); + ok(format!( + "{} Treffer, als Kacheln im Panel. Archiv: {}\n{}", + hits.len(), + home.display(), + list.join("\n") + )) +} diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 55f555a..e60a545 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -5,7 +5,7 @@ use std::io::{Read, Write}; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; -use crate::domain::paths::{panel_file, Paths}; +use crate::domain::paths::{commands_file, panel_file, search_file, wiki_file, Paths}; use crate::domain::project::{project_pool_dir, terminal_config, TerminalConfig}; use crate::domain::registry::project_dir; use crate::domain::settings::claude_command; @@ -116,10 +116,28 @@ pub fn term_start( } let _ = std::fs::write(&panel_path, ""); + // Command-Kanal: History-Datei (JSONL) leeren — die Befehls-History ist + // flüchtig und gilt nur für diese Session; write_commands hängt Records an. + let commands_path = commands_file(&project); + let _ = std::fs::write(&commands_path, ""); + + // Such-Kanal: Treffer-Datei leeren — search_archive schreibt den jeweils + // letzten Suchlauf hinein, der Watcher zieht ihn als Kacheln ins Panel. + let search_path = search_file(&project); + let _ = std::fs::write(&search_path, ""); + + // Wiki-Kanal: Puffer leeren — show_archive und wiki_open schreiben die + // jeweils aktuelle Wiki-Seite (JSON) hinein. + let wiki_path = wiki_file(&project); + let _ = std::fs::write(&wiki_path, ""); + let mut cmd = crate::platform::shell_command(&claude_command(&paths)); cmd.cwd(&cwd); cmd.env("TERM", "xterm-256color"); cmd.env("AI_CONTROL_PANEL", &panel_path); + cmd.env("AI_CONTROL_COMMANDS", &commands_path); + cmd.env("AI_CONTROL_SEARCH", &search_path); + cmd.env("AI_CONTROL_WIKI", &wiki_path); cmd.env("AI_CONTROL_PROJECT", &project); if let Some(pool_dir) = project_pool_dir(&project)? { cmd.env("CLAUDE_CONFIG_DIR", pool_dir); @@ -177,7 +195,14 @@ pub fn term_start( let _ = app.emit_to(&label, "pty-exit", ()); }); - spawn_panel_watcher(window.app_handle().clone(), panel_path); + spawn_file_watcher(window.app_handle().clone(), panel_path, "panel-update"); + spawn_file_watcher( + window.app_handle().clone(), + commands_path, + "commands-update", + ); + spawn_file_watcher(window.app_handle().clone(), search_path, "search-update"); + spawn_file_watcher(window.app_handle().clone(), wiki_path, "wiki-update"); terminals.0.lock().unwrap().insert( window.label().to_string(), @@ -186,12 +211,12 @@ pub fn term_start( Ok(()) } -/// Beobachtet die Panel-Datei des Projekts und schickt neuen Inhalt als -/// `panel-update` an alle Fenster dieses Terminal-Prozesses (angedocktes Panel -/// und ein evtl. abgelöstes Panel-Fenster). Pollt per mtime — kein notify-Crate, -/// die Datei ändert sich nur, wenn ein Entwurf geschrieben wird. Der Thread -/// endet mit dem Prozess (Fenster zu). -fn spawn_panel_watcher(app: AppHandle, path: std::path::PathBuf) { +/// Beobachtet eine Panel-Datei des Projekts (Entwurf oder Command-History) +/// und schickt neuen Inhalt unter `event` an alle Fenster dieses +/// Terminal-Prozesses (angedocktes Panel und ein evtl. abgelöstes +/// Panel-Fenster). Pollt per mtime — kein notify-Crate, die Datei ändert sich +/// nur, wenn geschrieben wird. Der Thread endet mit dem Prozess (Fenster zu). +fn spawn_file_watcher(app: AppHandle, path: std::path::PathBuf, event: &'static str) { std::thread::spawn(move || { let mtime = || std::fs::metadata(&path).and_then(|m| m.modified()).ok(); let mut last = mtime(); @@ -201,7 +226,7 @@ fn spawn_panel_watcher(app: AppHandle, path: std::path::PathBuf) { if now != last { last = now; if let Ok(content) = std::fs::read_to_string(&path) { - let _ = app.emit("panel-update", content); + let _ = app.emit(event, content); } } } @@ -214,6 +239,40 @@ pub fn panel_read(project: String) -> String { std::fs::read_to_string(panel_file(&project)).unwrap_or_default() } +/// Aktuelle Command-History (JSONL; Erstbefüllung der Kachel-Ansicht). +#[tauri::command] +pub fn commands_read(project: String) -> String { + std::fs::read_to_string(commands_file(&project)).unwrap_or_default() +} + +/// Entfernt einen Befehl aus der Command-History (Löschen einer Kachel im +/// Panel). `line` ist der Index der nicht-leeren JSONL-Zeile, `entry` der +/// Index im commands-Array des Records; ein leer gewordener Record fällt mit +/// weg. Der Watcher meldet den neuen Stand als `commands-update`. +#[tauri::command] +pub fn commands_delete(project: String, line: usize, entry: usize) -> Result<(), String> { + let path = commands_file(&project); + let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let mut records: Vec = text + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).map_err(|e| e.to_string())) + .collect::>()?; + let cmds = records[line]["commands"] + .as_array_mut() + .ok_or("Record ohne commands")?; + cmds.remove(entry); + if cmds.is_empty() { + records.remove(line); + } + let mut out = String::new(); + for rec in &records { + out.push_str(&rec.to_string()); + out.push('\n'); + } + std::fs::write(&path, out).map_err(|e| e.to_string()) +} + /// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die /// Änderung als `panel-update` an alle Fenster. #[tauri::command] @@ -221,6 +280,53 @@ pub fn panel_set(project: String, text: String) -> Result<(), String> { std::fs::write(panel_file(&project), text).map_err(|e| e.to_string()) } +/// Letzter Suchlauf (JSON; Erstbefüllung der Treffer-Ansicht). +#[tauri::command] +pub fn search_read(project: String) -> String { + std::fs::read_to_string(search_file(&project)).unwrap_or_default() +} + +/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive und +/// schreibt die Treffer-Datei; der Watcher zieht sie in die Ansicht (beide +/// Fenster). +#[tauri::command] +pub fn search_run(project: String, query: String) -> Result<(), String> { + let home = crate::domain::archive::require_archive_home(&project)?; + let hits = crate::domain::archive_search::search(&home, &query, None, 20)?; + let payload = serde_json::json!({ + "query": query, + "tag": null, + "home": home.display().to_string(), + "hits": hits, + }); + std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string()) +} + +/// Aktueller Wiki-Puffer (JSON; Erstbefüllung der Wiki-Ansicht). +#[tauri::command] +pub fn wiki_read(project: String) -> String { + std::fs::read_to_string(wiki_file(&project)).unwrap_or_default() +} + +/// Öffnet ein Wiki-Ziel (Klick auf einen `[[…]]`-Link oder Suchtreffer): +/// `tag:x` → Schlagwort-Seite, `tag:` → Archiv-Übersicht, sonst +/// Dokument-Auflösung über den Index. Der `tag:`-Namensraum ist damit dort +/// interpretiert, wo archive_page ihn erzeugt — nicht im Frontend. Schreibt +/// den Wiki-Puffer; der Watcher meldet ihn als `wiki-update`. +#[tauri::command] +pub fn wiki_open(project: String, name: String) -> Result<(), String> { + let home = crate::domain::archive::require_archive_home(&project)?; + let json = match name.strip_prefix("tag:") { + Some(tag) => serde_json::to_string(&crate::domain::archive_index::archive_page( + &home, + (!tag.is_empty()).then_some(tag), + )?), + None => serde_json::to_string(&crate::domain::archive_index::wiki_doc(&home, &name)?), + } + .map_err(|e| e.to_string())?; + std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string()) +} + /// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach /// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus. #[tauri::command] @@ -233,16 +339,21 @@ pub fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> let cfg = terminal_config(&project)?; let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default()); let title = cfg.title.as_deref().unwrap_or(project.as_str()); - WebviewWindowBuilder::new( + let builder = WebviewWindowBuilder::new( &app, &label, WebviewUrl::App(format!("panel.html?project={project}").into()), ) - .title(format!("{title} — Entwurf")) + .title(format!("{title} — Dokument")) .inner_size(480.0, 640.0) - .background_color(tauri::window::Color(r, g, b, 0xff)) - .build() - .map_err(|e| e.to_string())?; + .background_color(tauri::window::Color(r, g, b, 0xff)); + + // Linux/GNOME: keine GTK-Deko — eigene Kopfleiste in panel.html, wie beim + // Terminal-Fenster. + #[cfg(target_os = "linux")] + let builder = builder.decorations(false); + + builder.build().map_err(|e| e.to_string())?; app.emit("panel-detached", ()).map_err(|e| e.to_string())?; Ok(()) } diff --git a/src/commands-view.ts b/src/commands-view.ts new file mode 100644 index 0000000..be04b1c --- /dev/null +++ b/src/commands-view.ts @@ -0,0 +1,199 @@ +/// Kachel-Ansicht der Command-History: rendert die JSONL-Datei +/// (write_commands im MCP-Server) als kopierbare Kacheln, Neuestes oben, +/// mit Zeitmarken und Session-Trennern. Löschen einer Kachel geht als +/// onDelete(line, entry) an den Aufrufer; der neue Stand kommt über den +/// Watcher zurück. DOM wird per createElement gebaut — Befehle sind +/// Fremdtext und gehen nie durch innerHTML. + +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; + +interface CommandEntry { + cmd: string; + note?: string; +} + +interface Record { + ts: number; + session?: boolean; + commands?: CommandEntry[]; +} + +export interface CommandsView { + set(text: string): void; + empty(): boolean; +} + +function fmtTime(ts: number): string { + const d = new Date(ts * 1000); + const hm = d.toLocaleTimeString("de", { hour: "2-digit", minute: "2-digit" }); + return d.toDateString() === new Date().toDateString() + ? hm + : `${d.toLocaleDateString("de")} ${hm}`; +} + +/// Kurzes visuelles Feedback (copied/error) — der eine Flash-Helper fürs +/// ganze Panel. +export function flash(el: HTMLElement, cls: string, ms = 1200) { + el.classList.add(cls); + setTimeout(() => el.classList.remove(cls), ms); +} + +/// Sichtbare Fehlermeldung im Panel: kurz eingeblendete Zeile oben rechts. +export function panelToast(msg: string) { + const t = document.createElement("div"); + t.className = "panel-toast"; + t.textContent = msg; + document.body.append(t); + setTimeout(() => t.remove(), 5000); +} + +function copyBtn(text: () => string): HTMLButtonElement { + const btn = document.createElement("button"); + btn.className = "panel-btn cmd-copy"; + btn.title = "Befehl kopieren"; + btn.innerHTML = + ''; + btn.addEventListener("click", async () => { + await writeText(text()); + flash(btn, "copied"); + }); + return btn; +} + +function deleteBtn(onClick: () => void): HTMLButtonElement { + const btn = document.createElement("button"); + btn.className = "panel-btn cmd-del"; + btn.title = "Aus der History entfernen"; + btn.innerHTML = + ''; + btn.addEventListener("click", onClick); + return btn; +} + +export function initCommandsView( + container: HTMLElement, + onDelete: (line: number, entry: number) => void, +): CommandsView { + let count = 0; + + function render(records: Record[]) { + container.textContent = ""; + // Neuestes oben: rückwärts durch die Records; ein Session-Marker trennt + // die davor liegenden (älteren) Einträge ab und steht daher unter ihnen. + // Der Record-Index entspricht der nicht-leeren JSONL-Zeile — er + // adressiert den Eintrag beim Löschen. + for (let i = records.length - 1; i >= 0; i--) { + const rec = records[i]; + if (rec.session) { + const sep = document.createElement("div"); + sep.className = "cmd-sep"; + sep.textContent = `Session · ${fmtTime(rec.ts)}`; + container.append(sep); + continue; + } + const cmds = rec.commands ?? []; + if (!cmds.length) continue; + const block = document.createElement("div"); + block.className = "cmd-rec"; + + const head = document.createElement("div"); + head.className = "cmd-rec-head"; + const time = document.createElement("span"); + time.textContent = fmtTime(rec.ts); + head.append(time); + if (cmds.length > 1) { + const all = document.createElement("button"); + all.className = "cmd-all"; + all.textContent = "Alle kopieren"; + all.addEventListener("click", async () => { + await writeText(cmds.map((c) => c.cmd).join("\n")); + flash(all, "copied"); + }); + head.append(all); + } + block.append(head); + + cmds.forEach((entry, j) => { + const tile = document.createElement("div"); + tile.className = "cmd-tile"; + const body = document.createElement("div"); + body.className = "cmd-body"; + const cmd = document.createElement("div"); + cmd.className = "cmd-text"; + cmd.textContent = entry.cmd; + body.append(cmd); + if (entry.note) { + const note = document.createElement("div"); + note.className = "cmd-note"; + note.textContent = entry.note; + body.append(note); + } + tile.append(body, copyBtn(() => entry.cmd), deleteBtn(() => onDelete(i, j))); + block.append(tile); + }); + container.append(block); + } + } + + return { + set(text: string) { + const records: Record[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + records.push(JSON.parse(line)); + } + count = records.filter((r) => r.commands?.length).length; + render(records); + }, + empty: () => count === 0, + }; +} + +export type PanelMode = "draft" | "commands" | "search" | "wiki"; + +const LABEL: { [m in PanelMode]: string } = { + draft: "Dokument", + commands: "Befehle", + search: "Suche", + wiki: "Wiki", +}; + +/// Vier Tabs Entwurf / Befehle / Suche / Wiki: blenden Entwurfs-Inhalt samt +/// zugehöriger Kopf-Controls gegen die jeweilige Ansicht aus. Ein Wechsel bei +/// offener Inhalts-Bearbeitung speichert den Entwurf (flush) statt zu +/// blockieren. +export function initPanelMode(opts: { + tabsEl: HTMLElement; + draftEls: HTMLElement[]; + commandsContent: HTMLElement; + searchContent: HTMLElement; + wikiContent: HTMLElement; + titleEl: HTMLElement; + flush: () => void; +}) { + let mode: PanelMode = "draft"; + let draftTitle = ""; + const tabs = [...opts.tabsEl.querySelectorAll("[data-mode]")]; + + function apply() { + opts.commandsContent.hidden = mode !== "commands"; + opts.searchContent.hidden = mode !== "search"; + opts.wikiContent.hidden = mode !== "wiki"; + for (const el of opts.draftEls) el.hidden = mode !== "draft"; + for (const t of tabs) t.classList.toggle("active", t.dataset.mode === mode); + if (mode !== "draft") opts.titleEl.textContent = LABEL[mode]; + } + + function to(m: PanelMode) { + if (m === mode) return; + opts.flush(); + if (mode === "draft") draftTitle = opts.titleEl.textContent || "Dokument"; + mode = m; + apply(); + if (m === "draft") opts.titleEl.textContent = draftTitle; + } + + for (const t of tabs) t.addEventListener("click", () => to(t.dataset.mode as PanelMode)); + apply(); + return { to, current: () => mode }; +} diff --git a/src/panel-tiles.css b/src/panel-tiles.css new file mode 100644 index 0000000..8ac8ec8 --- /dev/null +++ b/src/panel-tiles.css @@ -0,0 +1,430 @@ +/* Kachel- und Wiki-Ansichten des Panels (Befehls-History, Archiv-Suchtreffer, + Archiv-Wiki) — gemeinsam für das angedockte Panel (terminal.html) und das + abgelöste Fenster (panel.html); von beiden Entry-Points importiert. */ + +#commands-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 14px; +} +#commands-content::-webkit-scrollbar { + width: 8px; +} +#commands-content::-webkit-scrollbar-thumb { + background: #313244; + border-radius: 4px; +} +.cmd-sep { + margin: 16px 0 8px; + padding-top: 8px; + border-top: 1px solid #313244; + color: #6c7086; + font: + 500 11px/1 -apple-system, + system-ui, + sans-serif; + user-select: none; + -webkit-user-select: none; +} +.cmd-rec { + margin-bottom: 12px; +} +.cmd-rec-head { + display: flex; + align-items: center; + justify-content: space-between; + color: #6c7086; + font: + 500 11px/1 -apple-system, + system-ui, + sans-serif; + user-select: none; + -webkit-user-select: none; +} +.cmd-all { + border: none; + background: transparent; + color: #a6adc8; + font: inherit; + border-radius: 6px; + padding: 4px 6px; +} +.cmd-all:hover { + background: #313244; + color: #cdd6f4; +} +.cmd-all.copied { + color: #a6e3a1; +} +.cmd-del:hover { + color: #f38ba8; +} +.cmd-tile { + display: flex; + align-items: flex-start; + gap: 6px; + background: #11111b; + border: 1px solid #313244; + border-radius: 8px; + padding: 8px 6px 8px 10px; + margin-top: 6px; +} +.cmd-body { + flex: 1; + min-width: 0; +} +.cmd-text { + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-all; + user-select: text; + -webkit-user-select: text; + cursor: text; +} +.cmd-note { + margin-top: 4px; + color: #a6adc8; + font: + 400 11px/1.4 -apple-system, + system-ui, + sans-serif; +} +#search-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 14px; +} +.hit-search { + margin-bottom: 8px; +} +.hit-search input { + width: 100%; + box-sizing: border-box; + background: #11111b; + border: 1px solid #313244; + border-radius: 6px; + padding: 6px 10px; + color: #cdd6f4; + font: + 400 12px/1.4 -apple-system, + system-ui, + sans-serif; + outline: none; +} +.hit-search input:focus { + border-color: #45475a; +} +.hit-search input::placeholder { + color: #6c7086; +} +#search-content::-webkit-scrollbar { + width: 8px; +} +#search-content::-webkit-scrollbar-thumb { + background: #313244; + border-radius: 4px; +} +.hit-head { + color: #6c7086; + font: + 500 11px/1 -apple-system, + system-ui, + sans-serif; + user-select: none; + -webkit-user-select: none; +} +.hit-tile { + background: #11111b; + border: 1px solid #313244; + border-radius: 8px; + padding: 8px 10px; + margin-top: 6px; + cursor: pointer; +} +.hit-tile:hover { + border-color: #45475a; +} +.hit-title { + font: + 600 12px/1.4 -apple-system, + system-ui, + sans-serif; +} +.hit-snippet { + margin-top: 4px; + color: #a6adc8; + font: + 400 11px/1.5 -apple-system, + system-ui, + sans-serif; +} +.hit-snippet mark { + background: transparent; + color: #f9e2af; + font-weight: 600; +} +.hit-path { + margin-top: 4px; + color: #6c7086; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 10px; +} + +/* Wiki-Ansicht: Übersichts-/Schlagwort-Seiten und Dokumente. */ +#wiki-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 14px 16px; +} +#wiki-content::-webkit-scrollbar { + width: 8px; +} +#wiki-content::-webkit-scrollbar-thumb { + background: #313244; + border-radius: 4px; +} +.wiki-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.wiki-head-left { + min-width: 0; +} +.wiki-head-title { + color: #cdd6f4; + font: + 600 13px/1.3 -apple-system, + system-ui, + sans-serif; +} +.wiki-head-sub { + margin-top: 2px; + color: #6c7086; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wiki-head-right { + color: #6c7086; + font: + 500 11px/1 -apple-system, + system-ui, + sans-serif; + white-space: nowrap; +} +.wiki-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 10px 0 4px; +} +.wiki-chip { + border: 1px solid #313244; + background: transparent; + color: #a6adc8; + border-radius: 999px; + padding: 2px 8px; + font: + 500 10.5px/1.5 -apple-system, + system-ui, + sans-serif; +} +.wiki-chip:hover { + background: #313244; + color: #cdd6f4; +} +.wiki-chip.active { + background: #313244; + border-color: #45475a; + color: #89b4fa; +} +.wiki-folder { + margin: 14px 0 2px; + padding-top: 10px; + border-top: 1px solid #313244; + color: #6c7086; + font: + 500 10px/1 -apple-system, + system-ui, + sans-serif; + letter-spacing: 0.08em; + text-transform: uppercase; + user-select: none; + -webkit-user-select: none; +} +.wiki-doc { + padding: 7px 8px; + margin: 4px -8px 0; + border-radius: 8px; + cursor: pointer; +} +.wiki-doc:hover { + background: #11111b; +} +.wiki-doc-line { + display: flex; + align-items: baseline; + gap: 8px; +} +.wiki-doc-title { + flex: 1; + min-width: 0; + color: #89b4fa; + font: + 600 12px/1.4 -apple-system, + system-ui, + sans-serif; +} +.wiki-doc-date { + color: #6c7086; + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 10px; +} +.wiki-doc-desc { + margin-top: 2px; + color: #a6adc8; + font: + 400 11px/1.5 -apple-system, + system-ui, + sans-serif; +} +.wiki-doc-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 5px; +} +.wiki-empty { + margin-top: 18px; + color: #a6adc8; + font: + 400 12px/1.6 -apple-system, + system-ui, + sans-serif; +} +.wiki-empty strong { + display: block; + margin-bottom: 6px; + color: #cdd6f4; + font-weight: 600; +} +.wiki-doc-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.wiki-back { + border: none; + background: transparent; + color: #a6adc8; + font: + 500 11px/1 -apple-system, + system-ui, + sans-serif; + border-radius: 6px; + padding: 4px 6px; +} +.wiki-back:hover { + background: #313244; + color: #cdd6f4; +} +.wiki-body { + font-size: 12.5px; + line-height: 1.55; + user-select: text; + -webkit-user-select: text; + cursor: text; +} +.wiki-body h1, +.wiki-body h2, +.wiki-body h3 { + line-height: 1.25; + margin: 1em 0 0.4em; +} +.wiki-body h1 { + font-size: 1.4em; +} +.wiki-body h2 { + font-size: 1.2em; +} +.wiki-body h3 { + font-size: 1.05em; +} +.wiki-body :first-child { + margin-top: 0; +} +.wiki-body p, +.wiki-body ul, +.wiki-body ol, +.wiki-body pre, +.wiki-body blockquote { + margin: 0 0 0.7em; +} +.wiki-body code { + font-family: "JetBrains Mono", Menlo, monospace; + font-size: 0.9em; + background: #313244; + border-radius: 4px; + padding: 1px 5px; +} +.wiki-body pre { + background: #11111b; + border-radius: 8px; + padding: 12px 14px; + overflow: auto; +} +.wiki-body pre code { + background: none; + padding: 0; +} +.wiki-body blockquote { + border-left: 3px solid #313244; + padding-left: 12px; + color: #a6adc8; +} +.wiki-body a { + color: #89b4fa; +} +.wiki-backlinks { + margin-top: 14px; + padding-top: 8px; + border-top: 1px solid #313244; + color: #6c7086; + font: + 500 11px/1.6 -apple-system, + system-ui, + sans-serif; +} +.wiki-backlinks a { + color: #89b4fa; + text-decoration: none; +} + +/* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */ +.panel-toast { + position: fixed; + top: 44px; + right: 12px; + z-index: 10; + max-width: 320px; + background: #11111b; + border: 1px solid #f38ba8; + color: #f38ba8; + border-radius: 8px; + padding: 8px 12px; + font: + 500 11px/1.5 -apple-system, + system-ui, + sans-serif; +} diff --git a/src/panel-view.ts b/src/panel-view.ts index c8d1bb2..4971e12 100644 --- a/src/panel-view.ts +++ b/src/panel-view.ts @@ -1,8 +1,13 @@ import { marked } from "marked"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import { flash } from "./commands-view"; export interface PanelView { set(text: string): void; raw(): string; + /// Schreibt eine offene Inhalts-Bearbeitung zurück (und beendet sie); + /// aufgelöst, sobald der Entwurf gespeichert ist. + flush(): Promise; } /// Verkabelt einen Panel-Inhaltsbereich: MD/Roh-Umschalter und Copy-Button. @@ -22,7 +27,7 @@ function stripInlineMd(s: string): string { } /// Titel für den Panel-Kopf: erste Überschrift (# …) oder sonst erste -/// nicht-leere Zeile, ohne Inline-Markdown; „Entwurf" bei leerem Text. +/// nicht-leere Zeile, ohne Inline-Markdown; „Dokument" bei leerem Text. function firstLine(text: string): string { let fallback = ""; for (const line of text.split("\n")) { @@ -32,7 +37,7 @@ function firstLine(text: string): string { if (t.startsWith("#") && h) return stripInlineMd(h); if (!fallback) fallback = t; } - return fallback ? stripInlineMd(fallback) : "Entwurf"; + return fallback ? stripInlineMd(fallback) : "Dokument"; } /// Ersetzt die erste Überschrift (bzw. legt eine an) im Rohtext durch `title`. @@ -52,6 +57,45 @@ function setHeading(text: string, title: string): string { return `# ${title}\n`; } +/// Macht `[[name]]`-Wikilinks im gerendertem Markdown klickbar. Läuft über die +/// Textknoten des DOM statt über den Rohtext, damit Vorkommen in Code-Spans +/// und Code-Blöcken (z. B. bash `[[ -f x ]]`) unangetastet bleiben. +export function linkWikiRefs(root: HTMLElement, onClick: (name: string) => void) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes: Text[] = []; + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const t = n as Text; + if (!t.textContent?.includes("[[")) continue; + if (t.parentElement?.closest("code, pre, a")) continue; + nodes.push(t); + } + for (const node of nodes) { + const parts = node.textContent!.split(/\[\[([^\]]+)\]\]/g); + if (parts.length < 3) continue; + const frag = document.createDocumentFragment(); + parts.forEach((part, i) => { + if (i % 2) { + // `[[ziel]]` oder `[[ziel|label]]`. + const sep = part.indexOf("|"); + const target = sep < 0 ? part : part.slice(0, sep); + const label = sep < 0 ? part : part.slice(sep + 1); + const a = document.createElement("a"); + a.href = "#"; + a.className = "wiki"; + a.textContent = label; + a.addEventListener("click", (e) => { + e.preventDefault(); + onClick(target); + }); + frag.append(a); + } else if (part) { + frag.append(part); + } + }); + node.replaceWith(frag); + } +} + export function initPanelView(opts: { content: HTMLElement; copyBtn: HTMLElement; @@ -64,7 +108,9 @@ export function initPanelView(opts: { defaultLang?: string; /// Wird mit dem neuen Rohtext aufgerufen, wenn Titel oder Inhalt geändert /// wurden. - onCommit?: (text: string) => void; + onCommit?: (text: string) => void | Promise; + /// Klick auf einen `[[name]]`-Wikilink im gerenderten Markdown. + onWikiLink?: (name: string) => void; }): PanelView { let rawText = ""; let rendered = true; @@ -72,11 +118,13 @@ export function initPanelView(opts: { // gepuffert statt angewandt. let editing = false; let pending: string | null = null; + let flushEditor: () => Promise = async () => {}; function draw() { if (rendered) { opts.content.className = "md"; opts.content.innerHTML = marked.parse(rawText, { async: false }); + if (opts.onWikiLink) linkWikiRefs(opts.content, opts.onWikiLink); } else { opts.content.className = "raw"; opts.content.textContent = rawText; @@ -90,9 +138,8 @@ export function initPanelView(opts: { }); opts.copyBtn.addEventListener("click", async () => { - await navigator.clipboard.writeText(rawText); - opts.copyBtn.classList.add("copied"); - setTimeout(() => opts.copyBtn.classList.remove("copied"), 1200); + await writeText(rawText); + flash(opts.copyBtn, "copied"); }); // Titel-Edit: Edit-Button macht den Titel editierbar, Enter/Blur schreibt die @@ -172,11 +219,13 @@ export function initPanelView(opts: { editBtn.classList.add("active"); editor.focus(); }; - const save = () => { + flushEditor = async () => { + if (!editing) return; const v = editor.value; leave(); - opts.onCommit!(v); // schreibt Datei -> panel-update -> set() rendert + await opts.onCommit!(v); // schreibt Datei -> panel-update -> set() rendert }; + const save = () => void flushEditor(); const cancel = () => { const p = pending; leave(); @@ -216,5 +265,6 @@ export function initPanelView(opts: { applyText(text); }, raw: () => rawText, + flush: () => flushEditor(), }; } diff --git a/src/panel-wiring.ts b/src/panel-wiring.ts new file mode 100644 index 0000000..b3c521d --- /dev/null +++ b/src/panel-wiring.ts @@ -0,0 +1,117 @@ +/// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und +/// das abgelöste Fenster (panel.ts): Entwurfs-Ansicht, Befehls- und +/// Such-Kacheln, Modus-Umschalter, Wikilink-Klick und die drei Update-Events. +/// Die Element-IDs sind in beiden HTML-Dateien identisch; die Draft-Controls +/// trägt das Markup als `.draft-only`. + +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { initPanelView, type PanelView } from "./panel-view"; +import { + initCommandsView, + initPanelMode, + panelToast, + type CommandsView, + type PanelMode, +} from "./commands-view"; +import { initSearchView } from "./search-view"; +import { initWikiView } from "./wiki-view"; +import "./panel-tiles.css"; + +export interface PanelWiring { + view: PanelView; + cmdView: CommandsView; + mode: { to(m: PanelMode): void; current(): PanelMode }; + /// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung). + draft: string; +} + +export async function wirePanel( + project: string, + onIncoming?: () => void, +): Promise { + const titleEl = document.querySelector(".panel-title") as HTMLElement; + const [defaultLang, draft, cmds, search, wiki] = await Promise.all([ + invoke("spellcheck_lang"), + invoke("panel_read", { project }), + invoke("commands_read", { project }), + invoke("search_read", { project }), + invoke("wiki_read", { project }), + ]); + + // Jeder Wiki-Sprung (Wikilink, Chip, Suchtreffer) geht als ein Invoke an den + // Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück. + const openWiki = (name: string) => invoke("wiki_open", { project, name }); + + const view = initPanelView({ + content: document.getElementById("panel-content")!, + copyBtn: document.getElementById("panel-copy")!, + modeBtn: document.getElementById("panel-mode")!, + titleEl, + editBtn: document.getElementById("panel-title-edit")!, + editContentBtn: document.getElementById("panel-content-edit")!, + langSelect: document.getElementById("panel-lang") as HTMLSelectElement, + defaultLang, + 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 searchView = initSearchView( + document.getElementById("search-content")!, + (relpath) => openWiki(relpath.split("/").pop()!.replace(/\.md$/, "")), + (query) => + void invoke("search_run", { project, query }).catch((e) => + panelToast(`Suche fehlgeschlagen: ${e}`), + ), + ); + const wikiView = initWikiView(document.getElementById("wiki-content")!, openWiki); + const mode = initPanelMode({ + tabsEl: document.getElementById("panel-tabs")!, + draftEls: [ + document.getElementById("panel-content")!, + ...document.querySelectorAll(".draft-only"), + ], + commandsContent: document.getElementById("commands-content")!, + searchContent: document.getElementById("search-content")!, + wikiContent: document.getElementById("wiki-content")!, + titleEl, + flush: () => void view.flush(), + }); + + view.set(draft); + cmdView.set(cmds); + searchView.set(search); + wikiView.set(wiki); + + // Wiki-Tab bei leerem Puffer (Session-Start): Übersicht direkt laden. + document + .querySelector('#panel-tabs [data-mode="wiki"]')! + .addEventListener("click", () => { + if (wikiView.empty()) void openWiki("tag:"); + }); + + await listen("panel-update", (e) => { + view.set(e.payload); + mode.to("draft"); + onIncoming?.(); + }); + await listen("commands-update", (e) => { + cmdView.set(e.payload); + mode.to("commands"); + onIncoming?.(); + }); + await listen("search-update", (e) => { + searchView.set(e.payload); + mode.to("search"); + onIncoming?.(); + }); + await listen("wiki-update", (e) => { + wikiView.set(e.payload); + mode.to("wiki"); + onIncoming?.(); + }); + + return { view, cmdView, mode, draft }; +} diff --git a/src/panel.ts b/src/panel.ts index 9d4af7e..397a1c9 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -2,36 +2,59 @@ 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, emit } from "@tauri-apps/api/event"; -import { initPanelView } from "./panel-view"; +import { emit } from "@tauri-apps/api/event"; +import { wirePanel } from "./panel-wiring"; +import { flash, panelToast } from "./commands-view"; +import { THEMES } from "./themes"; // Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt -// danach denselben `panel-update`-Events wie das angedockte Panel. +// danach denselben Update-Events wie das angedockte Panel; startet in +// „Befehle", wenn es (noch) keinen Entwurf gibt. const project = new URLSearchParams(location.search).get("project")!; const win = getCurrentWebviewWindow(); -const view = initPanelView({ - content: document.getElementById("panel-content")!, - copyBtn: document.getElementById("panel-copy")!, - 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 }), -}); +// Dekorationsloses Fenster (Linux): Plattform-Flag + Resize-Zonen wie im +// Terminal-Fenster; macOS behält die native Deko. +const isMac = /Mac|Macintosh/.test(navigator.userAgent); +document.documentElement.dataset.platform = isMac ? "mac" : "other"; +if (!isMac) { + document + .getElementById("win-min")! + .addEventListener("click", () => win.minimize()); + document + .getElementById("win-max")! + .addEventListener("click", () => win.toggleMaximize()); + for (const g of document.querySelectorAll(".grip")) { + g.addEventListener("mousedown", (e) => { + e.preventDefault(); + win.startResizeDragging(g.dataset.dir as never); + }); + } +} -view.set(await invoke("panel_read", { project })); +// Farben ans Theme koppeln — derselbe Look wie das angedockte Panel im +// Terminal-Fenster (die CSS-Defaults sind Mocha). +interface Project { + name: string; + terminal: { theme: string | null }; +} +const projects = await invoke("list_projects"); +const picked = THEMES[projects.find((p) => p.name === project)?.terminal.theme ?? "mocha"]; +document.documentElement.style.background = picked.header; +document.body.style.background = picked.header; +document.body.style.color = picked.xterm.foreground; +const topbar = document.querySelector(".panel-topbar") as HTMLElement; +topbar.style.background = picked.header; +topbar.style.borderBottomColor = picked.border; +const head = document.querySelector(".panel-head") as HTMLElement; +head.style.background = picked.header; +head.style.borderBottomColor = picked.border; -await listen("panel-update", (e) => view.set(e.payload)); +const { view, cmdView, mode, draft } = await wirePanel(project); +if (!draft.trim() && !cmdView.empty()) mode.to("commands"); // 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 () => { try { const configured = await invoke("panel_archive_dir_cmd", { @@ -44,11 +67,13 @@ archiveBtn.addEventListener("click", async () => { if (!chosen) return; dir = chosen as string; } + await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist const path = await invoke("panel_archive_cmd", { project, dir }); - flashBtn(archiveBtn, "copied"); + flash(archiveBtn, "copied", 1400); invoke("reveal_path_cmd", { path }); - } catch { - flashBtn(archiveBtn, "error"); + } catch (e) { + flash(archiveBtn, "error", 1400); + panelToast(`Archivieren fehlgeschlagen: ${e}`); } }); diff --git a/src/search-view.ts b/src/search-view.ts new file mode 100644 index 0000000..3cd242f --- /dev/null +++ b/src/search-view.ts @@ -0,0 +1,97 @@ +/// Kachel-Ansicht der Archiv-Suchtreffer: rendert die Suchtreffer-Datei +/// (search_archive im MCP-Server) als klickbare Kacheln — Klick reicht den +/// relpath des Treffers an onOpen. Titel/Snippet/Pfad sind Fremdtext und +/// gehen nie durch innerHTML; die `**…**`-Marker im Snippet werden per +/// Split in -Elemente übersetzt. + +interface Hit { + relpath: string; + title: string; + snippet: string; +} + +interface SearchRun { + query: string; + tag?: string | null; + home: string; + hits: Hit[]; +} + +export interface SearchView { + set(text: string): void; + empty(): boolean; +} + +export function initSearchView( + container: HTMLElement, + onOpen: (relpath: string) => void, + onSearch: (query: string) => void, +): SearchView { + let count = 0; + + // Suchfeld oben, Treffer darunter; das Feld bleibt über Updates hinweg stehen. + const bar = document.createElement("div"); + bar.className = "hit-search"; + const input = document.createElement("input"); + input.type = "search"; + input.placeholder = "Archiv durchsuchen — Enter startet"; + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && input.value.trim()) onSearch(input.value.trim()); + }); + bar.append(input); + const results = document.createElement("div"); + container.append(bar, results); + + function snippetEl(snippet: string): HTMLElement { + const div = document.createElement("div"); + div.className = "hit-snippet"; + snippet.split("**").forEach((part, i) => { + if (i % 2) { + const m = document.createElement("mark"); + m.textContent = part; + div.append(m); + } else if (part) { + div.append(document.createTextNode(part)); + } + }); + return div; + } + + function render(run: SearchRun) { + results.textContent = ""; + const head = document.createElement("div"); + head.className = "hit-head"; + const what = run.query.trim() ? `„${run.query}“` : ""; + const tag = run.tag ? `#${run.tag}` : ""; + head.textContent = `${run.hits.length} Treffer ${[what, tag].filter(Boolean).join(" · ")}`; + results.append(head); + for (const hit of run.hits) { + const tile = document.createElement("div"); + tile.className = "hit-tile"; + const title = document.createElement("div"); + title.className = "hit-title"; + title.textContent = hit.title; + const path = document.createElement("div"); + path.className = "hit-path"; + path.textContent = hit.relpath; + tile.append(title, snippetEl(hit.snippet), path); + tile.addEventListener("click", () => onOpen(hit.relpath)); + results.append(tile); + } + } + + return { + set(text: string) { + if (!text.trim()) { + count = 0; + results.textContent = ""; + return; + } + const run: SearchRun = JSON.parse(text); + count = run.hits.length; + if (document.activeElement !== input) input.value = run.query; + render(run); + }, + empty: () => count === 0, + }; +} diff --git a/src/terminal.ts b/src/terminal.ts index ba882c5..3bdfa55 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -10,7 +10,9 @@ import "@fontsource/jetbrains-mono/700.css"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { listen } from "@tauri-apps/api/event"; -import { initPanelView } from "./panel-view"; +import { wirePanel } from "./panel-wiring"; +import { THEMES } from "./themes"; +import { flash, panelToast } from "./commands-view"; // Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln. function showError(msg: string) { @@ -57,362 +59,6 @@ if (!isMac) { // Fensterhintergrund beim Öffnen kommt aus terminal.rs (theme_background); // dort nicht gepflegte Themes fallen auf Mocha zurück (nur kurzer Moment // bis die Webview lädt). -const THEMES: Record< - string, - { header: string; border: string; xterm: Record } -> = { - mocha: { - header: "#181825", - border: "#313244", - xterm: { - background: "#1e1e2e", - foreground: "#cdd6f4", - cursor: "#f5e0dc", - cursorAccent: "#1e1e2e", - selectionBackground: "#585b7080", - black: "#45475a", - red: "#f38ba8", - green: "#a6e3a1", - yellow: "#f9e2af", - blue: "#89b4fa", - magenta: "#f5c2e7", - cyan: "#94e2d5", - white: "#bac2de", - brightBlack: "#585b70", - brightRed: "#f38ba8", - brightGreen: "#a6e3a1", - brightYellow: "#f9e2af", - brightBlue: "#89b4fa", - brightMagenta: "#f5c2e7", - brightCyan: "#94e2d5", - brightWhite: "#a6adc8", - }, - }, - dracula: { - header: "#21222c", - border: "#44475a", - xterm: { - background: "#282a36", - foreground: "#f8f8f2", - cursor: "#f8f8f2", - cursorAccent: "#282a36", - selectionBackground: "#44475a80", - black: "#21222c", - red: "#ff5555", - green: "#50fa7b", - yellow: "#f1fa8c", - blue: "#bd93f9", - magenta: "#ff79c6", - cyan: "#8be9fd", - white: "#f8f8f2", - brightBlack: "#6272a4", - brightRed: "#ff6e6e", - brightGreen: "#69ff94", - brightYellow: "#ffffa5", - brightBlue: "#d6acff", - brightMagenta: "#ff92df", - brightCyan: "#a4ffff", - brightWhite: "#ffffff", - }, - }, - "solarized-dark": { - header: "#073642", - border: "#586e75", - xterm: { - background: "#002b36", - foreground: "#839496", - cursor: "#839496", - cursorAccent: "#002b36", - selectionBackground: "#07364280", - black: "#073642", - red: "#dc322f", - green: "#859900", - yellow: "#b58900", - blue: "#268bd2", - magenta: "#d33682", - cyan: "#2aa198", - white: "#eee8d5", - brightBlack: "#586e75", - brightRed: "#cb4b16", - brightGreen: "#859900", - brightYellow: "#657b83", - brightBlue: "#839496", - brightMagenta: "#6c71c4", - brightCyan: "#93a1a1", - brightWhite: "#fdf6e3", - }, - }, - gruvbox: { - header: "#1d2021", - border: "#3c3836", - xterm: { - background: "#282828", - foreground: "#ebdbb2", - cursor: "#ebdbb2", - cursorAccent: "#282828", - selectionBackground: "#3c383680", - black: "#282828", - red: "#cc241d", - green: "#98971a", - yellow: "#d79921", - blue: "#458588", - magenta: "#b16286", - cyan: "#689d6a", - white: "#a89984", - brightBlack: "#928374", - brightRed: "#fb4934", - brightGreen: "#b8bb26", - brightYellow: "#fabd2f", - brightBlue: "#83a598", - brightMagenta: "#d3869b", - brightCyan: "#8ec07c", - brightWhite: "#ebdbb2", - }, - }, - "one-dark": { - header: "#21252b", - border: "#3e4451", - xterm: { - background: "#282c34", - foreground: "#abb2bf", - cursor: "#abb2bf", - cursorAccent: "#282c34", - selectionBackground: "#3e445180", - black: "#282c34", - red: "#e06c75", - green: "#98c379", - yellow: "#e5c07b", - blue: "#61afef", - magenta: "#c678dd", - cyan: "#56b6c2", - white: "#abb2bf", - brightBlack: "#5c6370", - brightRed: "#e06c75", - brightGreen: "#98c379", - brightYellow: "#e5c07b", - brightBlue: "#61afef", - brightMagenta: "#c678dd", - brightCyan: "#56b6c2", - brightWhite: "#ffffff", - }, - }, - "solarized-light": { - header: "#eee8d5", - border: "#93a1a1", - xterm: { - background: "#fdf6e3", - foreground: "#657b83", - cursor: "#657b83", - cursorAccent: "#fdf6e3", - selectionBackground: "#eee8d5b0", - black: "#073642", - red: "#dc322f", - green: "#859900", - yellow: "#b58900", - blue: "#268bd2", - magenta: "#d33682", - cyan: "#2aa198", - white: "#eee8d5", - brightBlack: "#002b36", - brightRed: "#cb4b16", - brightGreen: "#586e75", - brightYellow: "#657b83", - brightBlue: "#839496", - brightMagenta: "#6c71c4", - brightCyan: "#93a1a1", - brightWhite: "#fdf6e3", - }, - }, - "catppuccin-latte": { - header: "#e6e9ef", - border: "#ccd0da", - xterm: { - background: "#eff1f5", - foreground: "#4c4f69", - cursor: "#dc8a78", - cursorAccent: "#eff1f5", - selectionBackground: "#acb0be80", - black: "#5c5f77", - red: "#d20f39", - green: "#40a02b", - yellow: "#df8e1d", - blue: "#1e66f5", - magenta: "#ea76cb", - cyan: "#179299", - white: "#acb0be", - brightBlack: "#6c6f85", - brightRed: "#d20f39", - brightGreen: "#40a02b", - brightYellow: "#df8e1d", - brightBlue: "#1e66f5", - brightMagenta: "#ea76cb", - brightCyan: "#179299", - brightWhite: "#bcc0cc", - }, - }, - "one-light": { - header: "#eaeaeb", - border: "#d4d4d4", - xterm: { - background: "#fafafa", - foreground: "#383a42", - cursor: "#526eff", - cursorAccent: "#fafafa", - selectionBackground: "#e5e5e6b0", - black: "#383a42", - red: "#e45649", - green: "#50a14f", - yellow: "#c18401", - blue: "#4078f2", - magenta: "#a626a4", - cyan: "#0184bc", - white: "#a0a1a7", - brightBlack: "#696c77", - brightRed: "#e45649", - brightGreen: "#50a14f", - brightYellow: "#c18401", - brightBlue: "#4078f2", - brightMagenta: "#a626a4", - brightCyan: "#0184bc", - brightWhite: "#ffffff", - }, - }, - nord: { - header: "#3b4252", - border: "#4c566a", - xterm: { - background: "#2e3440", - foreground: "#d8dee9", - cursor: "#d8dee9", - cursorAccent: "#2e3440", - selectionBackground: "#434c5e80", - black: "#3b4252", - red: "#bf616a", - green: "#a3be8c", - yellow: "#ebcb8b", - blue: "#81a1c1", - magenta: "#b48ead", - cyan: "#88c0d0", - white: "#e5e9f0", - brightBlack: "#4c566a", - brightRed: "#bf616a", - brightGreen: "#a3be8c", - brightYellow: "#ebcb8b", - brightBlue: "#81a1c1", - brightMagenta: "#b48ead", - brightCyan: "#8fbcbb", - brightWhite: "#eceff4", - }, - }, - "tokyo-night": { - header: "#16161e", - border: "#292e42", - xterm: { - background: "#1a1b26", - foreground: "#c0caf5", - cursor: "#c0caf5", - cursorAccent: "#1a1b26", - selectionBackground: "#28345780", - black: "#15161e", - red: "#f7768e", - green: "#9ece6a", - yellow: "#e0af68", - blue: "#7aa2f7", - magenta: "#bb9af7", - cyan: "#7dcfff", - white: "#a9b1d6", - brightBlack: "#414868", - brightRed: "#f7768e", - brightGreen: "#9ece6a", - brightYellow: "#e0af68", - brightBlue: "#7aa2f7", - brightMagenta: "#bb9af7", - brightCyan: "#7dcfff", - brightWhite: "#c0caf5", - }, - }, - monokai: { - header: "#1e1f1c", - border: "#49483e", - xterm: { - background: "#272822", - foreground: "#f8f8f2", - cursor: "#f8f8f2", - cursorAccent: "#272822", - selectionBackground: "#49483e80", - black: "#272822", - red: "#f92672", - green: "#a6e22e", - yellow: "#e6db74", - blue: "#66d9ef", - magenta: "#ae81ff", - cyan: "#a1efe4", - white: "#f8f8f2", - brightBlack: "#75715e", - brightRed: "#f92672", - brightGreen: "#a6e22e", - brightYellow: "#f4bf75", - brightBlue: "#66d9ef", - brightMagenta: "#ae81ff", - brightCyan: "#a1efe4", - brightWhite: "#f9f8f5", - }, - }, - "rose-pine": { - header: "#1f1d2e", - border: "#26233a", - xterm: { - background: "#191724", - foreground: "#e0def4", - cursor: "#e0def4", - cursorAccent: "#191724", - selectionBackground: "#403d5280", - black: "#26233a", - red: "#eb6f92", - green: "#31748f", - yellow: "#f6c177", - blue: "#9ccfd8", - magenta: "#c4a7e7", - cyan: "#ebbcba", - white: "#e0def4", - brightBlack: "#6e6a86", - brightRed: "#eb6f92", - brightGreen: "#31748f", - brightYellow: "#f6c177", - brightBlue: "#9ccfd8", - brightMagenta: "#c4a7e7", - brightCyan: "#ebbcba", - brightWhite: "#e0def4", - }, - }, - everforest: { - header: "#232a2e", - border: "#475258", - xterm: { - background: "#2d353b", - foreground: "#d3c6aa", - cursor: "#d3c6aa", - cursorAccent: "#2d353b", - selectionBackground: "#47525880", - black: "#475258", - red: "#e67e80", - green: "#a7c080", - yellow: "#dbbc7f", - blue: "#7fbbb3", - magenta: "#d699b6", - cyan: "#83c092", - white: "#d3c6aa", - brightBlack: "#859289", - brightRed: "#e67e80", - brightGreen: "#a7c080", - brightYellow: "#dbbc7f", - brightBlue: "#7fbbb3", - brightMagenta: "#d699b6", - brightCyan: "#83c092", - brightWhite: "#d3c6aa", - }, - }, -}; interface Project { name: string; @@ -538,7 +184,6 @@ term.focus(); // ein eigenes Fenster abgelöst (`panel-detached`). const panel = document.getElementById("panel")!; const splitter = document.getElementById("splitter")!; -const panelContent = document.getElementById("panel-content")!; // Panelfarben ans Theme koppeln. panel.style.background = picked.header; @@ -547,18 +192,6 @@ splitter.style.background = picked.border; (panel.querySelector(".panel-head") as HTMLElement).style.borderBottomColor = picked.border; -const view = initPanelView({ - content: panelContent, - copyBtn: document.getElementById("panel-copy")!, - 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 }), -}); - let detached = false; let hasContent = false; @@ -573,11 +206,19 @@ function hidePanel() { fit.fit(); } -await listen("panel-update", (e) => { - view.set(e.payload); +// Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende +// Update blendet das angedockte Panel ein, solange es nicht abgelöst ist. +const { view } = await wirePanel(project, () => { hasContent = true; if (!detached) showPanel(); }); + +// Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist. +document.getElementById("panel-tabs")!.addEventListener("click", () => { + hasContent = true; + if (!detached) showPanel(); +}); + await listen("panel-detached", () => { detached = true; hidePanel(); @@ -599,10 +240,6 @@ 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 () => { try { const configured = await invoke("panel_archive_dir_cmd", { @@ -615,11 +252,13 @@ archiveBtn.addEventListener("click", async () => { if (!chosen) return; dir = chosen as string; } + await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist const path = await invoke("panel_archive_cmd", { project, dir }); - flashBtn(archiveBtn, "copied"); + flash(archiveBtn, "copied", 1400); invoke("reveal_path_cmd", { path }); } catch (e) { - flashBtn(archiveBtn, "error"); + flash(archiveBtn, "error", 1400); + panelToast(`Archivieren fehlgeschlagen: ${e}`); invoke("term_log", { msg: `archive: ${e}` }); } }); diff --git a/src/themes.ts b/src/themes.ts new file mode 100644 index 0000000..4ef3b51 --- /dev/null +++ b/src/themes.ts @@ -0,0 +1,359 @@ +/// Farbschemata der Fenster: Header-/Rahmenfarbe plus xterm-Theme — +/// genutzt vom Terminal-Fenster (Header, angedocktes Panel) und vom +/// abgelösten Panel-Fenster (panel.ts), damit beide gleich aussehen. +export const THEMES: Record< + string, + { header: string; border: string; xterm: Record } +> = { + mocha: { + header: "#181825", + border: "#313244", + xterm: { + background: "#1e1e2e", + foreground: "#cdd6f4", + cursor: "#f5e0dc", + cursorAccent: "#1e1e2e", + selectionBackground: "#585b7080", + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#f5c2e7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f38ba8", + brightGreen: "#a6e3a1", + brightYellow: "#f9e2af", + brightBlue: "#89b4fa", + brightMagenta: "#f5c2e7", + brightCyan: "#94e2d5", + brightWhite: "#a6adc8", + }, + }, + dracula: { + header: "#21222c", + border: "#44475a", + xterm: { + background: "#282a36", + foreground: "#f8f8f2", + cursor: "#f8f8f2", + cursorAccent: "#282a36", + selectionBackground: "#44475a80", + black: "#21222c", + red: "#ff5555", + green: "#50fa7b", + yellow: "#f1fa8c", + blue: "#bd93f9", + magenta: "#ff79c6", + cyan: "#8be9fd", + white: "#f8f8f2", + brightBlack: "#6272a4", + brightRed: "#ff6e6e", + brightGreen: "#69ff94", + brightYellow: "#ffffa5", + brightBlue: "#d6acff", + brightMagenta: "#ff92df", + brightCyan: "#a4ffff", + brightWhite: "#ffffff", + }, + }, + "solarized-dark": { + header: "#073642", + border: "#586e75", + xterm: { + background: "#002b36", + foreground: "#839496", + cursor: "#839496", + cursorAccent: "#002b36", + selectionBackground: "#07364280", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#586e75", + brightRed: "#cb4b16", + brightGreen: "#859900", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", + }, + }, + gruvbox: { + header: "#1d2021", + border: "#3c3836", + xterm: { + background: "#282828", + foreground: "#ebdbb2", + cursor: "#ebdbb2", + cursorAccent: "#282828", + selectionBackground: "#3c383680", + black: "#282828", + red: "#cc241d", + green: "#98971a", + yellow: "#d79921", + blue: "#458588", + magenta: "#b16286", + cyan: "#689d6a", + white: "#a89984", + brightBlack: "#928374", + brightRed: "#fb4934", + brightGreen: "#b8bb26", + brightYellow: "#fabd2f", + brightBlue: "#83a598", + brightMagenta: "#d3869b", + brightCyan: "#8ec07c", + brightWhite: "#ebdbb2", + }, + }, + "one-dark": { + header: "#21252b", + border: "#3e4451", + xterm: { + background: "#282c34", + foreground: "#abb2bf", + cursor: "#abb2bf", + cursorAccent: "#282c34", + selectionBackground: "#3e445180", + black: "#282c34", + red: "#e06c75", + green: "#98c379", + yellow: "#e5c07b", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#abb2bf", + brightBlack: "#5c6370", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", + }, + }, + "solarized-light": { + header: "#eee8d5", + border: "#93a1a1", + xterm: { + background: "#fdf6e3", + foreground: "#657b83", + cursor: "#657b83", + cursorAccent: "#fdf6e3", + selectionBackground: "#eee8d5b0", + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#002b36", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", + }, + }, + "catppuccin-latte": { + header: "#e6e9ef", + border: "#ccd0da", + xterm: { + background: "#eff1f5", + foreground: "#4c4f69", + cursor: "#dc8a78", + cursorAccent: "#eff1f5", + selectionBackground: "#acb0be80", + black: "#5c5f77", + red: "#d20f39", + green: "#40a02b", + yellow: "#df8e1d", + blue: "#1e66f5", + magenta: "#ea76cb", + cyan: "#179299", + white: "#acb0be", + brightBlack: "#6c6f85", + brightRed: "#d20f39", + brightGreen: "#40a02b", + brightYellow: "#df8e1d", + brightBlue: "#1e66f5", + brightMagenta: "#ea76cb", + brightCyan: "#179299", + brightWhite: "#bcc0cc", + }, + }, + "one-light": { + header: "#eaeaeb", + border: "#d4d4d4", + xterm: { + background: "#fafafa", + foreground: "#383a42", + cursor: "#526eff", + cursorAccent: "#fafafa", + selectionBackground: "#e5e5e6b0", + black: "#383a42", + red: "#e45649", + green: "#50a14f", + yellow: "#c18401", + blue: "#4078f2", + magenta: "#a626a4", + cyan: "#0184bc", + white: "#a0a1a7", + brightBlack: "#696c77", + brightRed: "#e45649", + brightGreen: "#50a14f", + brightYellow: "#c18401", + brightBlue: "#4078f2", + brightMagenta: "#a626a4", + brightCyan: "#0184bc", + brightWhite: "#ffffff", + }, + }, + nord: { + header: "#3b4252", + border: "#4c566a", + xterm: { + background: "#2e3440", + foreground: "#d8dee9", + cursor: "#d8dee9", + cursorAccent: "#2e3440", + selectionBackground: "#434c5e80", + black: "#3b4252", + red: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + blue: "#81a1c1", + magenta: "#b48ead", + cyan: "#88c0d0", + white: "#e5e9f0", + brightBlack: "#4c566a", + brightRed: "#bf616a", + brightGreen: "#a3be8c", + brightYellow: "#ebcb8b", + brightBlue: "#81a1c1", + brightMagenta: "#b48ead", + brightCyan: "#8fbcbb", + brightWhite: "#eceff4", + }, + }, + "tokyo-night": { + header: "#16161e", + border: "#292e42", + xterm: { + background: "#1a1b26", + foreground: "#c0caf5", + cursor: "#c0caf5", + cursorAccent: "#1a1b26", + selectionBackground: "#28345780", + black: "#15161e", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#bb9af7", + cyan: "#7dcfff", + white: "#a9b1d6", + brightBlack: "#414868", + brightRed: "#f7768e", + brightGreen: "#9ece6a", + brightYellow: "#e0af68", + brightBlue: "#7aa2f7", + brightMagenta: "#bb9af7", + brightCyan: "#7dcfff", + brightWhite: "#c0caf5", + }, + }, + monokai: { + header: "#1e1f1c", + border: "#49483e", + xterm: { + background: "#272822", + foreground: "#f8f8f2", + cursor: "#f8f8f2", + cursorAccent: "#272822", + selectionBackground: "#49483e80", + black: "#272822", + red: "#f92672", + green: "#a6e22e", + yellow: "#e6db74", + blue: "#66d9ef", + magenta: "#ae81ff", + cyan: "#a1efe4", + white: "#f8f8f2", + brightBlack: "#75715e", + brightRed: "#f92672", + brightGreen: "#a6e22e", + brightYellow: "#f4bf75", + brightBlue: "#66d9ef", + brightMagenta: "#ae81ff", + brightCyan: "#a1efe4", + brightWhite: "#f9f8f5", + }, + }, + "rose-pine": { + header: "#1f1d2e", + border: "#26233a", + xterm: { + background: "#191724", + foreground: "#e0def4", + cursor: "#e0def4", + cursorAccent: "#191724", + selectionBackground: "#403d5280", + black: "#26233a", + red: "#eb6f92", + green: "#31748f", + yellow: "#f6c177", + blue: "#9ccfd8", + magenta: "#c4a7e7", + cyan: "#ebbcba", + white: "#e0def4", + brightBlack: "#6e6a86", + brightRed: "#eb6f92", + brightGreen: "#31748f", + brightYellow: "#f6c177", + brightBlue: "#9ccfd8", + brightMagenta: "#c4a7e7", + brightCyan: "#ebbcba", + brightWhite: "#e0def4", + }, + }, + everforest: { + header: "#232a2e", + border: "#475258", + xterm: { + background: "#2d353b", + foreground: "#d3c6aa", + cursor: "#d3c6aa", + cursorAccent: "#2d353b", + selectionBackground: "#47525880", + black: "#475258", + red: "#e67e80", + green: "#a7c080", + yellow: "#dbbc7f", + blue: "#7fbbb3", + magenta: "#d699b6", + cyan: "#83c092", + white: "#d3c6aa", + brightBlack: "#859289", + brightRed: "#e67e80", + brightGreen: "#a7c080", + brightYellow: "#dbbc7f", + brightBlue: "#7fbbb3", + brightMagenta: "#d699b6", + brightCyan: "#83c092", + brightWhite: "#d3c6aa", + }, + }, +}; diff --git a/src/wiki-view.ts b/src/wiki-view.ts new file mode 100644 index 0000000..23b60e7 --- /dev/null +++ b/src/wiki-view.ts @@ -0,0 +1,226 @@ +/// Wiki-Ansicht des Panels: rendert den Wiki-Puffer (show_archive/wiki_open). +/// Übersichts- und Schlagwort-Seiten kommen als strukturierte Daten (Kopfzeile, +/// Schlagwort-Chips, Ordner-Sektionen mit Dokumentzeilen), Dokumente als +/// gerendertes Markdown mit Backlinks. Titel, Beschreibungen und Pfade sind +/// Fremdtext und gehen nie durch innerHTML; nur der Markdown-Rumpf läuft wie +/// im Entwurf durch marked. + +import { marked } from "marked"; +import { linkWikiRefs } from "./panel-view"; + +interface DocEntry { + name: string; + title: string; + description?: string | null; + tags: string[]; + date?: string | null; +} + +interface Folder { + name: string; + docs: DocEntry[]; +} + +interface Page { + kind: "page"; + home: string; + tag?: string | null; + total: number; + tags: { name: string; count: number }[]; + folders: Folder[]; +} + +interface DocPage { + kind: "doc"; + home: string; + relpath: string; + name: string; + title: string; + tags: string[]; + backlinks: string[]; + markdown: string; +} + +export interface WikiView { + set(text: string): void; + /// Noch keine Seite im Puffer (Session-Start)? + empty(): boolean; +} + +export function initWikiView( + container: HTMLElement, + onLink: (name: string) => void, +): WikiView { + function chip(label: string, target: string, active = false): HTMLElement { + const b = document.createElement("button"); + b.className = "wiki-chip" + (active ? " active" : ""); + b.textContent = label; + b.addEventListener("click", (e) => { + e.stopPropagation(); + onLink(target); + }); + return b; + } + + /// Kopfzeile einer Seite: Titel + Home-Pfad links, Dokumentzahl rechts. + function pageHead(p: Page): HTMLElement { + const head = document.createElement("div"); + head.className = "wiki-head"; + const left = document.createElement("div"); + left.className = "wiki-head-left"; + const title = document.createElement("div"); + title.className = "wiki-head-title"; + title.textContent = p.tag ? `#${p.tag}` : "Archiv"; + const sub = document.createElement("div"); + sub.className = "wiki-head-sub"; + sub.textContent = p.home; + left.append(title, sub); + const count = document.createElement("div"); + count.className = "wiki-head-right"; + count.textContent = `${p.total} ${p.total === 1 ? "Dokument" : "Dokumente"}`; + head.append(left, count); + return head; + } + + /// Schlagwort-Leiste: „Alle“ plus ein Chip pro Schlagwort mit Zähler; der + /// aktive Filter ist markiert. + function tagBar(p: Page): HTMLElement { + const bar = document.createElement("div"); + bar.className = "wiki-chips"; + bar.append(chip("Alle", "tag:", !p.tag)); + for (const t of p.tags) { + bar.append(chip(`#${t.name} ${t.count}`, `tag:${t.name}`, p.tag === t.name)); + } + return bar; + } + + function docRow(doc: DocEntry): HTMLElement { + const row = document.createElement("div"); + row.className = "wiki-doc"; + const line = document.createElement("div"); + line.className = "wiki-doc-line"; + const title = document.createElement("div"); + title.className = "wiki-doc-title"; + title.textContent = doc.title; + line.append(title); + if (doc.date) { + const date = document.createElement("div"); + date.className = "wiki-doc-date"; + date.textContent = doc.date; + line.append(date); + } + row.append(line); + if (doc.description) { + const desc = document.createElement("div"); + desc.className = "wiki-doc-desc"; + desc.textContent = doc.description; + row.append(desc); + } + if (doc.tags.length) { + const tags = document.createElement("div"); + tags.className = "wiki-doc-tags"; + for (const t of doc.tags) tags.append(chip(`#${t}`, `tag:${t}`)); + row.append(tags); + } + row.addEventListener("click", () => onLink(doc.name)); + return row; + } + + function renderPage(p: Page) { + container.append(pageHead(p)); + if (p.tags.length) container.append(tagBar(p)); + if (p.total === 0) { + const empty = document.createElement("div"); + empty.className = "wiki-empty"; + const line = document.createElement("strong"); + line.textContent = p.tag ? `Keine Dokumente mit #${p.tag}.` : "Das Archiv ist leer."; + empty.append(line); + if (!p.tag) { + empty.append( + "Archivieren: Archiv-Button im Entwurf oder „archiviere das“ im Chat — mit Ordner, Beschreibung und Schlagwörtern.", + ); + } + container.append(empty); + return; + } + for (const folder of p.folders) { + if (folder.name) { + const eyebrow = document.createElement("div"); + eyebrow.className = "wiki-folder"; + eyebrow.textContent = `${folder.name}/`; + container.append(eyebrow); + } + for (const doc of folder.docs) container.append(docRow(doc)); + } + } + + function renderDoc(d: DocPage) { + const head = document.createElement("div"); + head.className = "wiki-doc-head"; + const back = document.createElement("button"); + back.className = "wiki-back"; + back.textContent = "‹ Archiv"; + back.addEventListener("click", () => onLink("tag:")); + const path = document.createElement("div"); + path.className = "wiki-head-sub"; + path.textContent = d.relpath; + head.append(back, path); + container.append(head); + if (d.tags.length) { + const tags = document.createElement("div"); + tags.className = "wiki-chips"; + for (const t of d.tags) tags.append(chip(`#${t}`, `tag:${t}`)); + container.append(tags); + } + const body = document.createElement("div"); + body.className = "wiki-body"; + body.innerHTML = marked.parse(d.markdown, { async: false }); + linkWikiRefs(body, onLink); + container.append(body); + if (d.backlinks.length) { + const back = document.createElement("div"); + back.className = "wiki-backlinks"; + back.append("Verweise hierher: "); + d.backlinks.forEach((name, i) => { + if (i) back.append(" · "); + const a = document.createElement("a"); + a.href = "#"; + a.className = "wiki"; + a.textContent = name; + a.addEventListener("click", (e) => { + e.preventDefault(); + onLink(name); + }); + back.append(a); + }); + container.append(back); + } + } + + /// Leerer Puffer: Einstieg statt leerer Fläche. + function renderIntro() { + const empty = document.createElement("div"); + empty.className = "wiki-empty"; + const line = document.createElement("strong"); + line.textContent = "Keine Wiki-Seite geladen."; + empty.append(line); + empty.append(chip("Archiv-Übersicht öffnen", "tag:")); + container.append(empty); + } + + let loaded = false; + return { + set(text: string) { + container.textContent = ""; + loaded = !!text.trim(); + if (!loaded) { + renderIntro(); + return; + } + const data: Page | DocPage = JSON.parse(text); + if (data.kind === "doc") renderDoc(data); + else renderPage(data); + }, + empty: () => !loaded, + }; +} diff --git a/terminal.html b/terminal.html index 585f270..f068388 100644 --- a/terminal.html +++ b/terminal.html @@ -50,8 +50,18 @@ #project-icon[hidden] { display: none; } - #pool { + /* Tab-Leiste im Header: Befehle │ Dokument · Wiki · Suche. */ + header .panel-tabs { margin-left: auto; + } + .tab-sep { + width: 1px; + height: 16px; + background: #45475a; + margin: 0 6px; + align-self: center; + } + #pool { background: #313244; color: #a6adc8; border-radius: 999px; @@ -230,6 +240,10 @@ gap: 2px; min-width: 0; } + /* Ablösen/Ausblenden rechts in der Titelzeile — in jedem Modus da. */ + #panel-detach { + margin-left: auto; + } .panel-actions { display: flex; align-items: center; @@ -303,17 +317,26 @@ .panel-editor[hidden] { display: none; } + .panel-tabs { + display: flex; + gap: 2px; + } .panel-lang { + appearance: none; + -webkit-appearance: none; margin-right: auto; - background: #313244; + background: transparent; color: #a6adc8; border: none; border-radius: 6px; - padding: 3px 4px; + padding: 6px 9px; font: inherit; - font-size: 11px; cursor: default; } + .panel-lang:hover { + background: #313244; + color: #cdd6f4; + } #panel-content { flex: 1; min-height: 0; @@ -388,12 +411,25 @@ #panel-content.md a { color: #89b4fa; } + .panel-btn[hidden], + .panel-lang[hidden], + #panel-content[hidden] { + display: none; + }
+
+ + + + + + +
+ Dokument + + +
- @@ -426,25 +468,22 @@ - - + - - - -
+ + +