Archiv-Wiki im Panel: vier Tabs, FTS5-Suche, strukturierbares Archiv

- Archiv-Home pro Projekt (archiveHome in ai-control.json); archive_panel
  mit Ordner, Beschreibung und Schlagwörtern (Frontmatter + Zeitstempel-Stem)
- Archiv-Index (archive_index.rs): Scan mit Frontmatter, Wikilinks,
  Backlinks; Wiki-Seiten als strukturierte Daten (Übersicht/Tag-Seiten)
- FTS5-Suche (archive_search.rs, rusqlite bundled): in-memory-Index pro
  Anfrage; MCP search_archive und Panel-Suchfeld (search_run) teilen die Engine
- Panel: vier Puffer/Ansichten (Dokument, Befehle, Suche, Wiki) mit Tabs im
  Fenster-Header; gemeinsame Verdrahtung panel-wiring.ts, Kachel-CSS,
  Wiki-View mit Tag-Chips, Ordner-Sektionen und Backlinks
- Archivieren speichert offene Bearbeitung (flush); Tab-Wechsel speichert
  statt zu blockieren; Fehler sichtbar als Panel-Toast
- Abgelöstes Fenster: rahmenlos (Linux), eigene Kopfleiste mit Tabs und
  Fensterknöpfen, Theme-Kopplung über gemeinsames themes.ts
- MCP-Tools show_commands, show_archive; Befehls-History mit Kachel-Löschen
This commit is contained in:
marcus hinz
2026-07-19 15:25:40 +02:00
parent d4d455fa08
commit 994d5070f0
29 changed files with 3471 additions and 571 deletions
+71 -30
View File
@@ -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/<pool>`. 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 <project>`) — 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 <project>`), 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
<project directory>/ 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 `<poolSyncDir>/<pool>`, 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.
+10
View File
@@ -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",
+1
View File
@@ -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",
+194 -17
View File
@@ -2,7 +2,7 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Entwurf</title>
<title>Dokument</title>
<style>
html,
body {
@@ -37,6 +37,140 @@
gap: 2px;
min-width: 0;
}
/* Kopfzeile wie der Terminal-Header: eigene Leiste voller Höhe mit
Tabs rechtsbündig vor den Fensterknöpfen (Andocken/Schließen). */
.panel-topbar {
height: 40px;
flex: none;
display: flex;
align-items: center;
padding-left: 14px;
background: #181825;
border-bottom: 1px solid #313244;
font:
500 13px/1 -apple-system,
system-ui,
sans-serif;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
.panel-topbar .panel-tabs {
margin-left: auto;
}
/* Fensterknöpfe rechts, volle Header-Höhe — wie im Terminal-Fenster. */
.winbtn {
width: 44px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: inherit;
cursor: default;
transition: background 0.12s ease;
}
.winbtn svg {
stroke: currentColor;
stroke-width: 1.2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.8;
}
.winbtn:hover {
background: #313244;
}
.winbtn:hover svg {
opacity: 1;
}
.winbtn.close:hover {
background: #f38ba8;
color: #11111b;
}
/* macOS hat native Fensterknöpfe — eigene Min/Max dort ausblenden. */
:root[data-platform="mac"] .winbtn.osctl {
display: none;
}
/* Resize-Zonen für das dekorationslose Fenster (nur Linux). */
#resize-grips {
display: none;
}
:root[data-platform="other"] #resize-grips {
display: block;
}
.grip {
position: fixed;
z-index: 100;
}
.grip.n {
top: 0;
left: 0;
right: 0;
height: 4px;
cursor: ns-resize;
}
.grip.s {
bottom: 0;
left: 0;
right: 0;
height: 4px;
cursor: ns-resize;
}
.grip.w {
top: 0;
bottom: 0;
left: 0;
width: 4px;
cursor: ew-resize;
}
.grip.e {
top: 0;
bottom: 0;
right: 0;
width: 4px;
cursor: ew-resize;
}
.grip.nw {
top: 0;
left: 0;
width: 9px;
height: 9px;
cursor: nwse-resize;
z-index: 101;
}
.grip.ne {
top: 0;
right: 0;
width: 9px;
height: 9px;
cursor: nesw-resize;
z-index: 101;
}
.grip.sw {
bottom: 0;
left: 0;
width: 9px;
height: 9px;
cursor: nesw-resize;
z-index: 101;
}
.grip.se {
bottom: 0;
right: 0;
width: 9px;
height: 9px;
cursor: nwse-resize;
z-index: 101;
}
.tab-sep {
width: 1px;
height: 16px;
background: #45475a;
margin: 0 6px;
align-self: center;
}
.panel-actions {
display: flex;
align-items: center;
@@ -110,17 +244,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;
@@ -195,16 +338,43 @@
#panel-content.md a {
color: #89b4fa;
}
.panel-btn[hidden],
.panel-lang[hidden],
#panel-content[hidden] {
display: none;
}
</style>
</head>
<body>
<div class="panel-topbar" data-tauri-drag-region>
<div class="panel-tabs" id="panel-tabs">
<button class="panel-btn" data-mode="commands" title="Befehls-History">Befehle</button>
<span class="tab-sep"></span>
<button class="panel-btn" data-mode="draft" title="Dokument">Dokument</button>
<button class="panel-btn" data-mode="wiki" title="Archiv-Wiki">Wiki</button>
<button class="panel-btn" data-mode="search" title="Suchtreffer">Suche</button>
<span class="tab-sep"></span>
</div>
<button class="winbtn" id="panel-dock" title="Wieder andocken">
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5" /><path d="M9.5 2.5v11" /><path d="M4 8h3.4M6 6.2 7.8 8 6 9.8" /></svg>
</button>
<button class="winbtn osctl" id="win-min" aria-label="Minimieren">
<svg width="12" height="12" viewBox="0 0 12 12"><line x1="2" y1="6.5" x2="10" y2="6.5" /></svg>
</button>
<button class="winbtn osctl" id="win-max" aria-label="Maximieren">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" rx="1" /></svg>
</button>
<button class="winbtn close" id="panel-close" title="Panel schließen">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg>
</button>
</div>
<div class="panel-head">
<div class="panel-titlerow" data-tauri-drag-region>
<span class="panel-title">Entwurf</span>
<button class="panel-btn panel-edit" id="panel-title-edit" title="Titel bearbeiten"></button>
<span class="panel-title">Dokument</span>
<button class="panel-btn panel-edit draft-only" id="panel-title-edit" title="Titel bearbeiten"></button>
</div>
<div class="panel-actions">
<select class="panel-lang" id="panel-lang" title="Sprache der Rechtschreibprüfung">
<select class="panel-lang draft-only" id="panel-lang" title="Sprache der Rechtschreibprüfung">
<option value="de">DE</option>
<option value="en">EN</option>
<option value="en-GB">EN-GB</option>
@@ -213,25 +383,32 @@
<option value="it">IT</option>
<option value="nl">NL</option>
</select>
<button class="panel-btn" id="panel-mode" title="Rohtext / gerendert">MD</button>
<button class="panel-btn" id="panel-content-edit" title="Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)">
<button class="panel-btn draft-only" id="panel-mode" title="Rohtext / gerendert">MD</button>
<button class="panel-btn draft-only" id="panel-content-edit" title="Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)">
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M10.8 2.6 13.4 5.2 6 12.6l-3.1.5.5-3.1z" /><path d="M9.7 3.7 12.3 6.3" /></svg>
</button>
<button class="panel-btn" id="panel-copy" title="In die Zwischenablage kopieren">
<button class="panel-btn draft-only" id="panel-copy" title="In die Zwischenablage kopieren">
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>
</button>
<button class="panel-btn" id="panel-archive" title="In den Archiv-Ordner speichern">
<button class="panel-btn draft-only" id="panel-archive" title="In den Archiv-Ordner speichern">
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="3.5" rx="1" /><path d="M2.8 6v6.5a1 1 0 0 0 1 1h8.4a1 1 0 0 0 1-1V6" /><path d="M6.3 9h3.4" /></svg>
</button>
<button class="panel-btn" id="panel-dock" title="Wieder andocken">
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5" /><path d="M9.5 2.5v11" /><path d="M4 8h3.4M6 6.2 7.8 8 6 9.8" /></svg>
</button>
<button class="panel-btn" id="panel-close" title="Panel schließen">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg>
</button>
</div>
</div>
<div id="panel-content" class="md"></div>
<div id="commands-content" hidden></div>
<div id="search-content" hidden></div>
<div id="wiki-content" hidden></div>
<div id="resize-grips" aria-hidden="true">
<div class="grip n" data-dir="North"></div>
<div class="grip s" data-dir="South"></div>
<div class="grip w" data-dir="West"></div>
<div class="grip e" data-dir="East"></div>
<div class="grip nw" data-dir="NorthWest"></div>
<div class="grip ne" data-dir="NorthEast"></div>
<div class="grip sw" data-dir="SouthWest"></div>
<div class="grip se" data-dir="SouthEast"></div>
</div>
<script type="module" src="/src/panel.ts"></script>
</body>
</html>
+370 -2
View File
@@ -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"
+3
View File
@@ -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]
+2 -1
View File
@@ -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"
]
}
+20 -10
View File
@@ -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.
+8
View File
@@ -277,6 +277,7 @@ pub(crate) fn main_builder() -> tauri::Builder<tauri::Wry> {
pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
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<tauri::Wry> {
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,
+17 -5
View File
@@ -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<Vec<UsageRow>, 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<String> {
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<String>) -> Result<String, String> {
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<Vec<crate::domain::archive_index::Doc>, 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").
+180 -15
View File
@@ -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<PathBuf> {
pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
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<PathBuf, String> {
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<String>,
/// Einzeiler fürs Frontmatter.
pub(crate) description: Option<String>,
/// Schlagwörter fürs Frontmatter.
pub(crate) tags: Vec<String>,
}
/// 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<PathBuf, String> {
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<String> =
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<String, String> {
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<String> {
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");
+376
View File
@@ -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<String>,
pub(crate) tags: Vec<String>,
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
pub(crate) links: Vec<String>,
/// Namen der Dokumente, die per Wikilink hierher zeigen.
pub(crate) backlinks: Vec<String>,
}
/// 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<Vec<Doc>, 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<Vec<(Doc, String)>, 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<String, usize> = 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<String>> = vec![Vec::new(); docs.len()];
for (i, (doc, _)) in docs.iter().enumerate() {
let mut targets: std::collections::BTreeSet<usize> = 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<String>,
pub(crate) total: usize,
pub(crate) tags: Vec<TagCount>,
pub(crate) folders: Vec<WikiFolder>,
}
#[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<WikiDocEntry>,
}
#[derive(serde::Serialize)]
pub(crate) struct WikiDocEntry {
pub(crate) name: String,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) tags: Vec<String>,
/// Archivierungsdatum aus dem Zeitstempel-Stem (`YYYY-MM-DD`).
pub(crate) date: Option<String>,
}
pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, String> {
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<String, Vec<&Doc>> =
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<String>,
pub(crate) backlinks: Vec<String>,
pub(crate) markdown: String,
}
pub(crate) fn wiki_doc(home: &Path, target: &str) -> Result<WikiDocPage, String> {
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<String> {
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());
}
}
+139
View File
@@ -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<Vec<Hit>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
/// MATCH-Ausdruck aus Query und optionalem Tag-Filter.
fn match_expr(query: &str, tag: Option<&str>) -> Result<String, String> {
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<Connection, String> {
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());
}
}
+2
View File
@@ -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;
+27
View File
@@ -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 == "~" {
+13 -9
View File
@@ -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__<key>__<tool>`.
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__<server>__<tool>`.
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__<server>__<tool>`.
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) {
+8 -5
View File
@@ -29,13 +29,16 @@ pub(crate) struct ProjectConfig {
pub(crate) pool: Option<String>,
#[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<String>,
pub(crate) archive_home: Option<String>,
}
#[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, &reg)?;
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()));
}
+298 -37
View File
@@ -66,35 +66,142 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
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<Value> {
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<String, Value> {
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<String> = 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")
))
}
+125 -14
View File
@@ -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<serde_json::Value> = text
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).map_err(|e| e.to_string()))
.collect::<Result<_, _>>()?;
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(())
}
+199
View File
@@ -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 =
'<svg width="14" height="14" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>';
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 =
'<svg width="14" height="14" viewBox="0 0 16 16"><path d="M2.5 4.5h11" /><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5" /><path d="M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6" /></svg>';
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<HTMLElement>("[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 };
}
+430
View File
@@ -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;
}
+58 -8
View File
@@ -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<void>;
}
/// 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<void>;
/// 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<void> = 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(),
};
}
+117
View File
@@ -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<PanelWiring> {
const titleEl = document.querySelector(".panel-title") as HTMLElement;
const [defaultLang, draft, cmds, search, wiki] = await Promise.all([
invoke<string>("spellcheck_lang"),
invoke<string>("panel_read", { project }),
invoke<string>("commands_read", { project }),
invoke<string>("search_read", { project }),
invoke<string>("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<HTMLElement>(".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<HTMLElement>('#panel-tabs [data-mode="wiki"]')!
.addEventListener("click", () => {
if (wikiView.empty()) void openWiki("tag:");
});
await listen<string>("panel-update", (e) => {
view.set(e.payload);
mode.to("draft");
onIncoming?.();
});
await listen<string>("commands-update", (e) => {
cmdView.set(e.payload);
mode.to("commands");
onIncoming?.();
});
await listen<string>("search-update", (e) => {
searchView.set(e.payload);
mode.to("search");
onIncoming?.();
});
await listen<string>("wiki-update", (e) => {
wikiView.set(e.payload);
mode.to("wiki");
onIncoming?.();
});
return { view, cmdView, mode, draft };
}
+48 -23
View File
@@ -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<string>("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<HTMLElement>(".grip")) {
g.addEventListener("mousedown", (e) => {
e.preventDefault();
win.startResizeDragging(g.dataset.dir as never);
});
}
}
view.set(await invoke<string>("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<Project[]>("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<string>("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<string | null>("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<string>("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}`);
}
});
+97
View File
@@ -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 <mark>-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,
};
}
+17 -378
View File
@@ -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<string, string> }
> = {
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<string>("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<string>("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<string | null>("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<string>("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}` });
}
});
+359
View File
@@ -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<string, string> }
> = {
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",
},
},
};
+226
View File
@@ -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,
};
}
+56 -17
View File
@@ -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;
}
</style>
</head>
<body>
<header data-tauri-drag-region>
<img id="project-icon" hidden data-tauri-drag-region />
<span id="project-name" data-tauri-drag-region></span>
<div class="panel-tabs" id="panel-tabs">
<button class="panel-btn" data-mode="commands" title="Befehls-History">Befehle</button>
<span class="tab-sep"></span>
<button class="panel-btn" data-mode="draft" title="Dokument">Dokument</button>
<button class="panel-btn" data-mode="wiki" title="Archiv-Wiki">Wiki</button>
<button class="panel-btn" data-mode="search" title="Suchtreffer">Suche</button>
<span class="tab-sep"></span>
</div>
<span id="pool"></span>
<div id="winbtns">
<button class="winbtn" id="win-min" aria-label="Minimieren">
@@ -413,11 +449,17 @@
<aside id="panel" hidden>
<div class="panel-head">
<div class="panel-titlerow">
<span class="panel-title">Entwurf</span>
<button class="panel-btn panel-edit" id="panel-title-edit" title="Titel bearbeiten"></button>
<span class="panel-title">Dokument</span>
<button class="panel-btn panel-edit draft-only" id="panel-title-edit" title="Titel bearbeiten"></button>
<button class="panel-btn" id="panel-detach" title="In eigenes Fenster ablösen">
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M12.5 8.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1h4.5" /><path d="M10 2h4v4" /><path d="M14 2 8 8" /></svg>
</button>
<button class="panel-btn" id="panel-hide" title="Panel ausblenden">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg>
</button>
</div>
<div class="panel-actions">
<select class="panel-lang" id="panel-lang" title="Sprache der Rechtschreibprüfung">
<select class="panel-lang draft-only" id="panel-lang" title="Sprache der Rechtschreibprüfung">
<option value="de">DE</option>
<option value="en">EN</option>
<option value="en-GB">EN-GB</option>
@@ -426,25 +468,22 @@
<option value="it">IT</option>
<option value="nl">NL</option>
</select>
<button class="panel-btn" id="panel-mode" title="Rohtext / gerendert">MD</button>
<button class="panel-btn" id="panel-content-edit" title="Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)">
<button class="panel-btn draft-only" id="panel-mode" title="Rohtext / gerendert">MD</button>
<button class="panel-btn draft-only" id="panel-content-edit" title="Entwurf bearbeiten (Cmd/Ctrl+Enter speichert, Esc verwirft)">
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M10.8 2.6 13.4 5.2 6 12.6l-3.1.5.5-3.1z" /><path d="M9.7 3.7 12.3 6.3" /></svg>
</button>
<button class="panel-btn" id="panel-copy" title="In die Zwischenablage kopieren">
<button class="panel-btn draft-only" id="panel-copy" title="In die Zwischenablage kopieren">
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.2V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.2" /></svg>
</button>
<button class="panel-btn" id="panel-archive" title="In den Archiv-Ordner speichern">
<button class="panel-btn draft-only" id="panel-archive" title="In den Archiv-Ordner speichern">
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="3.5" rx="1" /><path d="M2.8 6v6.5a1 1 0 0 0 1 1h8.4a1 1 0 0 0 1-1V6" /><path d="M6.3 9h3.4" /></svg>
</button>
<button class="panel-btn" id="panel-detach" title="In eigenes Fenster ablösen">
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M12.5 8.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1h4.5" /><path d="M10 2h4v4" /><path d="M14 2 8 8" /></svg>
</button>
<button class="panel-btn" id="panel-hide" title="Panel ausblenden">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg>
</button>
</div>
</div>
<div id="panel-content" class="md"></div>
<div id="commands-content" hidden></div>
<div id="search-content" hidden></div>
<div id="wiki-content" hidden></div>
</aside>
</div>
<div id="resize-grips" aria-hidden="true">