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:
@@ -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
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+370
-2
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 == "~" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ®)?;
|
||||
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
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user