Compare commits

...

23 Commits

Author SHA1 Message Date
marcus hinz 47951a615b Trilium als Archiv: verwaltete Instanz pro Projekt, MD-Archiv und Dokument-Tab entfernt
- domain/trilium.rs: Runtime-Download (gepinnt 0.104.0), Instanz pro Projekt auf 127.0.0.1
  mit FNV-Port, ETAPI (archive_note, search_notes), Token im Keyring
- Setup bis aufs Passwort automatisiert: trilium_connect macht new-document (skipDemoDb),
  set-password und ETAPI-Login; Modul-Flag erst nach Token-Erfolg
- write_panel legt direkt eine Trilium-Notiz an (text|path, folder/description/tags);
  noteopen-Kanal, das Terminal-Fenster öffnet die Notiz (ersetzt archive_panel)
- MD-Altbestand raus: archive*/wiki*, archiveHome samt Permissions, panel_set/panel_load/
  wiki_open, panel_archive*/archive_docs/*_archive_home, reveal_path, spellcheck
- Dokument-Tab raus: Panel = ToDo/Befehle/Suche + Archiv-Knopf (trilium_open);
  panel-view/archive-form gelöscht, Markup/CSS/Messages bereinigt
- Lösch-Stufe Archiv: Instanz stoppen, Trilium-Datadir und Keyring-Token löschen
2026-07-24 21:42:03 +02:00
marcus hinz f58b9887c7 Archiv-Navigator: Baum nur Ordner + flache Inhaltsliste (Zeilen: öffnen/umbenennen/löschen), Ordner/Dokument anlegen (Dateiname ohne Stempel, Datum im Frontmatter), Write-back geöffneter Archiv-Dokumente über Quell-Verknüpfung; Ablösen übergibt aktiven Tab 2026-07-23 21:17:41 +02:00
marcus hinz 1523856ea7 Modul-System (Registry Backend+Frontend, generierte Tabs, Settings-Sektion); ToDo-Modul: persistente Liste mit Anlegen/Bearbeiten im Panel (todos_add/todos_update, Formular mit Abbrechen); alte Hook-Todoliste (OFFENE-PUNKTE.md) komplett entfernt 2026-07-23 11:35:29 +02:00
marcus hinz 513e1a768f Dopplungen konsolidiert: settings.json-Permissions-Helfer, pool.json-Lesen über read_pool, gemeinsame Helfer für Pool-Anlage, Projekt-Config, Icon-Übernahme und Running-Guard 2026-07-22 12:58:38 +02:00
marcus hinz f7a99b1176 README: Windows-follows wieder rein, nur Installer-Erwähnungen bleiben draußen 2026-07-22 12:38:00 +02:00
marcus hinz 8e95332552 README: Windows und AppImage gestrichen — beides wird nicht ausgeliefert 2026-07-22 12:35:54 +02:00
marcus hinz 704255e471 README: Screenshots ergänzt — Linux-Tray-Popups (GNOME/Cinnamon), Panel-Entwurf, Befehlskacheln, Archiv-Suche
Aufgenommen auf der VM-Flotte (0.3.0, echte App): Popups nach Auto-
Aktivierung der paketierten Extensions; Panel-Shots über ein Demo-Projekt
mit Inhalten über die Panel-Kanaldateien.
2026-07-22 12:04:48 +02:00
marcus hinz 257b2ca2ef Cinnamon-Extension automatisch aktivieren; README: Desktop-Integration explizit
- Neu cinnamon.rs: erweitert org.cinnamon enabled-extensions idempotent um
  ai-control-popup@local; Aufruf im SNI-Zweig von init_tray (is_cinnamon).
- README: GNOME-/Cinnamon-Extension und KWin-Script mit Pfad und Aufgabe
  benannt, Auto-Aktivierung dokumentiert (inkl. GNOME-Randfall nach
  Erstinstallation); Cinnamon-X11-Aussage korrigiert (Initial-Fokus).
2026-07-22 11:22:44 +02:00
marcusH f13186e667 macos: unbenutzte Konstante MOVE_DISMISSED entfernt
Der Settings-Key steht in settings.rs, die Konstante war ein
Ueberbleibsel und hat beim Release-Build gewarnt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:55:12 +02:00
marcusH ea08563411 Version 0.3.0, Selbstinstallation nach ~/Applications, Gatekeeper im README
Erster Start aus DMG oder Downloads bietet einmalig an, sich nach
~/Applications zu kopieren und von dort neu zu starten — der Weg dorthin
braucht kein Administrator-Passwort, anders als /Applications. Ein DMG
kann das nicht selbst leisten: der Applications-Alias darin ist ein
absoluter Symlink auf /Applications.

Die Kopie erbt per ditto die Quarantäne des Downloads, deshalb wird sie
am Ziel gelöst — sonst käme beim Neustart erneut die Gatekeeper-Warnung
für dieselbe, gerade laufende App. Ablehnen merkt sich die App
(moveOfferDismissed), im Debug-Build fragt sie nie.

README: ~/Applications als gleichwertiger Installationsort, dazu der
Weg über System-Einstellungen -> Datenschutz & Sicherheit fuer alle, die
kein Terminal aufmachen wollen.

0.8 ist fuer den Windows-Stand reserviert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:47:36 +02:00
marcusH 0faa1e48fd README: Anthropic-Abschnitt auf zwei klare Aussagen gebracht
"Claude and Anthropic" führt jetzt mit den beiden Punkten, auf die es
ankommt: keine Verbindung zu Anthropic, und kein Einfluss auf Lizenz
oder Nutzung — was mit Claude Code erlaubt ist, ist es hier auch, nicht
mehr und nicht weniger; jede Anfrage läuft unter dem eigenen Login.

Die Belege bleiben darunter: keine Tokens in der App, /login macht
claude selbst, Credentials im Keychain, Warnung vor dem Datei-Fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:04:43 +02:00
marcusH a6dd567c0a README: Architekturübersicht, CSP-Zeile nachgezogen
Neuer Abschnitt Architecture: die drei Prozessrollen des einen Binaries
(aus Project layout hierher gezogen, dort steht jetzt ein Verweis), der
gemeinsame Look über die Plattformen und der Anchor-Kontrakt als
Tabelle — ein Codepfad show_popup, drei Herkünfte der Platzierung.

Dazu der ehrliche Linux-Absatz: Desktop x Sessiontyp x Tray-Host ist
eine breite Matrix, die kein Release ganz testet; auf einer ungetesteten
Kombination läuft das Popup, kann aber beim ersten Mal woanders landen
als am Icon.

Die CSP-Zeile im Sicherheitsmodell nennt jetzt style-src ohne
unsafe-inline und den Grund, warum die Fenster-Styles in eigenen
CSS-Dateien liegen. An tauri.conf.json gegengelesen: unsafe-inline gibt
es nur in der devCsp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:56:08 +02:00
marcusH 4bc4174d24 README: Verbrauch und Ein-Klick-Session in die Feature-Liste
Beides konnte die App längst, stand aber nur weiter unten zwischen den
Konzepten — für den Verbrauch gab es sogar einen Screenshot, aber keine
Zeile Text.

Zeitraum und Kostenangabe an der UI abgelesen: UsageList bietet 7 oder
30 Tage, die Spalte weist die Kosten als Schätzung aus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:47:55 +02:00
marcusH df828daea9 Pools können auf ein vorhandenes Config-Verzeichnis verweisen
Wer claude schon benutzt, hat einen fertigen Login in ~/.claude. Bisher
begann ai-control trotzdem bei null: jeder Pool war ein neu angelegtes
Verzeichnis, der erste Sessionstart verlangte /login. Ein Pool kann jetzt
stattdessen auf ein bestehendes Verzeichnis zeigen.

pool.json trägt dafür optional `dir` (home-kontrahiert). Der Pool-Ordner
ist dann nur die Hülle für diese Datei; pool_config_dir() löst auf und
ist die einzige Stelle, über die Sessionstart, Verbrauchsstatistik,
Panel-Provisionierung und Keychain-Name gehen.

Zeigt ein Pool auf claudes Default-Verzeichnis, bleibt CLAUDE_CONFIG_DIR
ungesetzt: claude nimmt dieses Verzeichnis von sich aus und damit den
unsuffixierten Keychain-Eintrag, an dem der bestehende Login hängt (am
Keychain gegengeprüft — "Claude Code-credentials" ohne Suffix neben den
suffixierten Pool-Einträgen). Ein Verweis auf ein anderes Verzeichnis
setzt die Variable wie gewohnt.

Das verwiesene Verzeichnis gehört der App nicht: dort entsteht nur die
Panel-Ausstattung (Skill, MCP-Eintrag, Freigabe-Ergänzung, alles
additiv), keine settings.json-Grundausstattung. Löschen trifft nur die
Hülle; Runtime-Symlinks und das Zurücksetzen des Keychain-Eintrags sind
gesperrt. Ein Verzeichnis kann nur ein Pool belegen, Ziele innerhalb von
pools/ sind ausgeschlossen.

In der UI erscheint der Knopf nur, wenn ~/.claude existiert und noch
kein Pool darauf zeigt; die Liste zeigt den Pfad unter dem Namen und
statt "Zurücksetzen" den Hinweis, dass die Anmeldung claude gehört.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:43:00 +02:00
marcusH 6b4afd2bc5 Name aICentral: README neu, sichtbare Titel umgestellt
README trägt jetzt aICentral mit der Tagline "Get Claude Code organized"
als eigene Zeile, dazu ein Hinweis auf die Unabhängigkeit von Anthropic
(Claude/Claude Code als fremde Marken, rein beschreibend genannt).

Sichtbare Stellen mitgezogen: Hauptfenster-Titel, Überschrift der
Verwaltung, Seitentitel, Linux-Tray-Titel und der AppImage-Hinweis
unter GNOME. Technische Bezeichner bleiben ai-control — Binary,
Identifier, SNI-Id und der Popup-Fenstertitel, auf den KWin-Script und
GNOME-Extension matchen.

Außerdem im README: deploy-macos.sh und deploy-windows.ps1 ergänzt, die
Installationswege pro OS beschrieben (macOS nach ~/Applications, das
Build-Bundle wandert danach nach macos.noindex), Panel-Tabs englisch
benannt und die Zweisprachigkeit der Oberfläche erwähnt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:10:47 +02:00
marcus hinz e7f98dd072 CSP: style-src ohne unsafe-inline — Fenster-Styles aus den HTML-Köpfen nach src/*-window.css
terminal/panel/popup.html laden ihre Styles jetzt als erste Imports der
jeweiligen Entries (Kaskade wie zuvor: Fenster-CSS vor xterm/fonts); devCsp
behält unsafe-inline für die Vite-HMR-Style-Injektion.
2026-07-21 19:04:36 +02:00
marcus hinz bbb06e0814 Popup auf SNI-Desktops: Activate-Koordinaten (Anchor::Click) + Cinnamon-Extension
- SNI-Activate-Koordinaten gehen als Anchor::Click durch; Cinnamon liefert
  echte Werte, bei (0,0) bleibt der Cursor-Fallback (KDE/XFCE).
- Neue Cinnamon-Extension ai-control-popup@local: platziert das Popup unter
  Wayland am Zeiger (Work-Area-geklemmt, set_position ist dort wirkungslos)
  und gibt ihm unter X11 den initialen Fokus (Muffins Focus-Stealing-
  Prevention verwirft das present() der App).
- Extension in deb/rpm nach /usr/share/cinnamon/extensions paketiert.
- README: Cinnamon-Abschnitte und Installer-Teststand aktualisiert.

Verifiziert in der VM-Flotte: Cinnamon Wayland + X11, XFCE, KDE (Wayland),
GNOME (Wayland).
2026-07-21 11:24:18 +02:00
marcusH 1fa9ffcf1b i18n bis in die Terminal- und Panel-Fenster
Uebersetzt waren bisher nur die Vue-Fenster; Terminal, Panel und Popup hatten
ihre Beschriftungen hart auf Deutsch im Markup und in den Views -- ein
englisches UI blieb zur Haelfte deutsch.

Die Tabellen ziehen nach messages.ts um, dazu ein Vue-freier t() und ein
applyI18n(), das statisches Markup ueber data-i18n-Marker beschriftet.
i18n.ts bleibt der vue-i18n-Wrapper fuer die Vue-Fenster. So kommt kein Vue
in die Terminal- und Panel-Bundles (Terminal 657,02 -> 657,06 kB).
Neue Namensraeume: panel, popup, commands, archiveForm, search, wiki.
toLocaleTimeString folgt der Sprache statt fest 'de'.

Zwei Funde nebenbei:
- window.localStorage wird ueber try/catch gelesen. Gesperrter Storage
  (privater Modus) wirft schon beim Lesen, und unter Node 22 ueberschattet
  ein undefiniertes globales localStorage das der Testumgebung.
- Die Frontend-Tests liefen unbemerkt gegen happy-doms en-US.
  test-setup.ts nagelt die Sprache fest.

49 Tests (39 bestehende, 10 neue fuer Tabellen-Paritaet, t() und applyI18n).
2026-07-20 22:36:19 +02:00
marcusH 92f34cb64d macOS: API-Key nicht mehr ueber die Kommandozeile an security
add-generic-password bekam den Key als -w-Argument; damit stand er fuer die
Dauer des Aufrufs in der Prozessliste und war per ps fuer jeden Nutzer des
Rechners lesbar. Jetzt kommt -w ohne Wert, security liest das Secret ueber
stdin (zweimal, Eingabe und Bestaetigung). Die Eingabeaufforderungen landen
auf stderr und werden aus der Fehlermeldung geschnitten.
2026-07-20 22:17:21 +02:00
marcusH e15e128fd3 deploy-macos.sh installiert nach ~/Applications
Bisher hat das Skript nur das Dev-Bundle aus Launch Services ausgetragen und
den DMG-Pfad ausgegeben; installiert wurde von Hand. Linux und Windows
installieren in ihren Deploy-Skripten, macOS war der Ausreisser.

Jetzt: alte Kopie austragen und loeschen (ditto merget sonst in ein
bestehendes Bundle hinein), neues Bundle per ditto nach ~/Applications,
lsregister -f. Guards vorweg gegen fehlendes Bundle und laufende Instanz --
eine laufende App haelt Dateien offen und ergaebe ein halb ueberschriebenes
Bundle.
2026-07-20 22:08:35 +02:00
marcus hinz e6226cfc9f README überarbeitet: aiCentral-Branding, alle sechs MCP-Tools, UUID-Registry, Sicherheitsmodell (CSP/Capabilities), dreistufiges Projekt-Löschen, Build/Deploy-Stand, Installer-Teststand mit offenen X11-Befunden 2026-07-20 20:43:09 +02:00
marcus hinz cf2c25df79 Archiv-Wechsel mit Migrations-Option
Bei gesetztem Archiv-Home fragt ein Dialog nach: Dokumente ins neue
Archiv verschieben (Checkbox, standardmäßig aus — nichts wandert
implizit) oder liegen lassen. change_archive_home_cmd setzt das neue
Home, zieht auf Wunsch die Einträge um (gleiche Platte rename, sonst
Kopie + Löschen; Kollision im Ziel bricht laut ab) und nimmt die Rechte
des alten Ordners zurück.
2026-07-20 13:48:49 +02:00
marcus hinz 347fc70982 Veraltete autogenerierte Permissions entfernt (remove_project, delete_project) 2026-07-20 13:37:34 +02:00
112 changed files with 5321 additions and 5167 deletions
+121 -38
View File
@@ -1,30 +1,58 @@
# aICentral # aICentral
**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`.) **Get Claude Code organized**
Pool, project, and session management for [Claude Code](https://claude.com/claude-code) — a tray app with a built-in terminal, a draft panel, and a per-project document archive. macOS and Linux; Windows support follows. (Repository and binary name: `ai-control`.)
> All code in this project was generated by Claude (Claude Code). > All code in this project was generated by Claude (Claude Code).
<img src="docs/projects.png" width="640" alt="Projects"> <img src="docs/projects.png" width="640" alt="Projects">
## Purpose ## What it does
- **Several logins, one click apart** — keep separate Claude Code logins side by side (subscription accounts or API keys) and give every project the one it should run under. Each project starts with its own login; no re-login, no juggling environment variables. An existing login in `~/.claude` is picked up as a pool of its own, so nothing has to be set up twice.
- **Command history** — every shell command the assistant hands you lands beside the terminal as a copyable tile, with the session's full command history one click away.
- **Text panel** — Markdown drafts appear next to the terminal instead of scrolling past in the chat: readable, selectable, editable, and copyable in one go.
- **Archive with full-text search** — keep drafts as Markdown files per project, curated with folder, description, and tags; browse them as a wiki and search their full text.
- **What it costs** — tokens and estimated cost per login and project over the last 7 or 30 days, read straight from the session transcripts (retries deduplicated).
- **One click, one session** — the tray popup lists every project with its icon and a green dot when it runs; a click starts it or brings the running terminal to the front.
## How it works
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: 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. - **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. A pool can also *point at* an existing config directory instead: if `~/.claude` is there, it is offered as a pool, keeps its login, and is left otherwise untouched — the app adds panel access there and never deletes anything in it.
- **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). - **Projects** — arbitrary directories carrying their own identity in `<project>/.ai-control/config.json` (UUID, display name, terminal settings, archive home — this file syncs with the project). The machine-local registry `~/.config/ai-control/projects.json` maps that UUID to the directory and the assigned pool; paths under home are stored as `~/…` and therefore stable across machines.
- **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. - **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. - **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. - **Text panel** — a workspace beside the terminal with four tabs: command tiles, the current draft, an archive wiki, and archive search. Claude writes into it through a bundled MCP server instead of flooding the chat.
- **Archive** — drafts can be archived as Markdown files into a per-project archive home, curated with folder, description, and tags at archiving time; browsable as a wiki and searchable via full-text search.
- **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. - **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 ## Text panel and MCP tools
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: 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. Six tools:
- **`write_panel`** — Claude places a Markdown draft in the panel next to the terminal instead of printing it as chat prose. | Tool | Purpose |
- **`archive_panel`** — saves the current draft as a Markdown file into the project's archive directory (`archiveHome` in the project's `.ai-control/config.json`). |---|---|
| `write_panel` | Place a Markdown draft in the panel instead of printing it as chat prose; accepts inline text or a file path (the server reads the file itself). |
| `write_commands` | List shell commands for the user as copyable tiles (command + optional note), appended to the session's command history. |
| `show_commands` | Show the command history (tile view). |
| `archive_panel` | Save the current draft as a Markdown file into the project's archive home; takes `folder`, `description`, `tags` for the frontmatter. |
| `show_archive` | Render the archive overview as a wiki page — documents grouped by folder, with descriptions and clickable tag links; with `tag`, that tag's page. |
| `search_archive` | FTS5 full-text search over the archive (words, "phrases", prefix*); hits appear as tiles in the panel. |
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). The panel docks into the terminal window and can be detached into its own window. The header tab bar switches between **Commands** (command tiles), the current **document**, **Wiki**, and **Search** (live search, `#tag` filtering); without a configured archive home the archive tabs stay hidden. The draft's title is taken from its first heading and is editable, as is the content. Spell checking follows `spellcheckLang` (per-text override in the panel).
The archive lives in the project's `archiveHome` (set in `.ai-control/config.json`); documents are plain Markdown files with frontmatter — no database, the search index is built in-memory per query.
![Draft document in the panel beside the terminal](docs/panel-draft.png)
| Command tiles | Archive search |
|---|---|
| ![Command tiles](docs/panel-commands.png) | ![Archive search](docs/panel-search.png) |
The interface speaks English and German across all windows; the language follows the browser locale and can be switched in the app.
## Screenshots ## Screenshots
@@ -40,30 +68,76 @@ The panel docks into the terminal window and can be detached into its own window
### macOS ### 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: Prebuilt DMGs are attached to the releases. Open the DMG and drag `ai-control.app` onto the `Applications` shortcut — or drop it into your personal `~/Applications` instead, which works just as well and asks for no administrator password. On first launch from anywhere else (the DMG, your Downloads folder), the app offers to move itself to `~/Applications` and restarts from there.
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; clear it with whichever path you installed to:
```sh ```sh
xattr -d com.apple.quarantine /Applications/ai-control.app # or
xattr -d com.apple.quarantine ~/Applications/ai-control.app xattr -d com.apple.quarantine ~/Applications/ai-control.app
``` ```
Without the terminal: double-click the app, dismiss the warning, then open **System Settings → Privacy & Security**. Near the bottom of that page macOS now offers *Open Anyway* for the app it just blocked — click it and confirm. The warning is gone for good afterwards.
Or avoid the topic entirely and build from source (below) — self-built apps aren't quarantined. Or avoid the topic entirely and build from source (below) — self-built apps aren't quarantined.
### Linux ### Linux
Releases carry a `.deb` and an AppImage. Releases carry a `.deb` and an `.rpm`. Both install three desktop-integration pieces alongside the app: the **GNOME Shell extension** `ai-control-popup@local` (tray button + popup on GNOME, `/usr/share/gnome-shell/extensions/`), the **Cinnamon extension** `ai-control-popup@local` (popup placement on Wayland, initial focus on X11, `/usr/share/cinnamon/extensions/`), and the **KWin script** `ai-control-popup` (popup placement on KDE Wayland, `/usr/share/kwin/scripts/`).
- **deb** — recommended. It also installs the GNOME Shell extension and the KWin script that provide the tray/popup integration on GNOME and KDE Wayland. 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. On Cinnamon the bundled extension additionally places the popup at the tray icon on **Wayland** (a Wayland client cannot position its own window) and gives it initial focus on **X11** (Muffin's focus-stealing prevention would otherwise drop it).
- **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. The app **enables the matching piece automatically** on each start, per user and without prompting: GNOME via `gnome-extensions enable`, Cinnamon via the `org.cinnamon enabled-extensions` gsettings key, KDE via `kwriteconfig6` + KWin reconfigure. Cinnamon and KDE pick the change up immediately. GNOME Shell only scans for newly installed extensions at session start, so right after the very first package install the enable call can come too early — the tray button then appears after the next login; from then on it is always active.
| Tray popup on GNOME | Tray popup on Cinnamon |
|---|---|
| ![Tray popup on GNOME](docs/linux-gnome-popup.png) | ![Tray popup on Cinnamon](docs/linux-cinnamon-popup.png) |
### Installer tests ### 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. The installers are hand-tested per release. Current state: Fedora 44 via rpm on GNOME, KDE Plasma, Cinnamon, and XFCE (VM test fleet); Ubuntu 26.04 (GNOME), Kubuntu (KDE Plasma, Wayland) and Linux Mint (Cinnamon) via deb in earlier rounds. The two open findings from that round are resolved: popup placement on Cinnamon Wayland now comes from the bundled Cinnamon extension, and click-outside-to-close verified fine on XFCE and Cinnamon. macOS re-test of the current build is in progress.
### Windows ### Windows
Follows soon. Follows.
## Architecture
One binary, three process roles:
- **Main app** (`ai-control`) — tray, main window, management, session 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 config directory. On macOS it carries its own Dock icon and Cmd-Tab entry, on Linux its own app id and `.desktop` file.
- **MCP server** (`ai-control --mcp-panel`) — stdio server started by Claude Code itself; no GUI.
Running projects are recognised by their terminal processes (`pgrep` for `--terminal <project>`) — there is no state file that could go stale.
**One look, every platform.** Windows are frameless and draw their own header as the title bar, styled from theme variables: macOS keeps the traffic lights as an overlay, Linux gets its own window buttons. The tray popup is the same HTML window everywhere — what differs is only how the click arrives and who places the window.
**Placement follows a contract.** `show_popup` is a single code path; where the popup lands is decided by the anchor the tray integration hands it:
| Anchor | Where it comes from | Placement |
|---|---|---|
| `IconRect` | native tray (macOS, Windows) | popup's right edge at the tray icon — below the menu bar, above the taskbar |
| `Click` | StatusNotifierItem (KDE, XFCE, Cinnamon) | at the activate coordinates when the host supplies usable ones, otherwise at the pointer, clamped into the monitor |
| `Managed` | GNOME shell extension, KWin script, Cinnamon extension | the compositor places the window itself |
GNOME has no tray at all, so the bundled shell extension provides the icon and relays the click over D-Bus. On Wayland a window cannot position itself, which is why KDE and Cinnamon get a small script/extension for that job.
**Linux, honestly:** desktop × session type (X11/Wayland) × tray host is a wide matrix, and no release can test all of it. What was tested is listed under [Installer tests](#installer-tests). On an untested combination the popup works, but its first placement may sit somewhere other than at the tray icon.
## Security model
- **CSP** — the WebViews run under `default-src 'self'`; scripts, styles, and connections are limited to the app itself and the Tauri IPC endpoint (dev mode additionally allows the Vite dev server and inline styles for hot reload). `style-src` carries no `unsafe-inline`: the window styles live in their own CSS files rather than in the HTML heads, so the policy holds without an exception.
- **Per-window capabilities, deny-by-default** — four capability files, one per window group: `main` (management, broadest command set), `popup` (list/start projects only), `term-*` (PTY plus docked panel), `panel-*` (panel/archive channels only — no PTY, no management). A backend command is callable only from windows whose capability lists it.
- **Pools separate configuration, not access** — keychain entries are deliberately readable without a prompt; any process running as your user can read any pool key. No isolation promise is made or implied.
## Project deletion
Deleting a project is scoped in three stages, each preceded by a preview of the affected artifacts (integration files, archive permission, todo hook, panel files, archive documents, working directories):
1. **Integration only** — removes ai-control's traces: `.ai-control/`, registry entry, panel/session files, archive permission, todo hook, desktop file. The project itself stays untouched.
2. **+ Archive** — additionally deletes the archive home with its documents.
3. **Full** — additionally deletes the project directory (working directories only with an explicit extra flag).
## Project layout ## Project layout
@@ -73,33 +147,35 @@ Follows soon.
├── panel.html detached text panel window ├── panel.html detached text panel window
├── popup.html tray popup ├── popup.html tray popup
├── src/ frontend: Vue 3 + TypeScript ├── src/ frontend: Vue 3 + TypeScript
│ (incl. wiki-view, search-view, archive-form,
│ commands-view + vitest tests)
├── src-tauri/ ├── src-tauri/
│ ├── src/lib.rs entry point, process roles │ ├── src/lib.rs entry point, process roles
│ ├── src/app.rs Tauri wiring: tray, main/popup windows, watcher │ ├── src/app.rs Tauri wiring: tray, main/popup windows, watcher
│ ├── src/mcp.rs MCP stdio server (write_panel / archive_panel) │ ├── src/mcp.rs MCP stdio server (6 tools, see above)
│ ├── src/terminal.rs PTY sessions (portable-pty), terminal windows │ ├── src/terminal.rs PTY sessions (portable-pty), terminal windows
│ ├── src/domain/ registry, project config, settings, watcher,
│ │ deletion scopes
│ ├── src/platform/ macOS / Linux / Windows specifics │ ├── src/platform/ macOS / Linux / Windows specifics
│ ├── capabilities/ per-window ACL (main/popup/terminal/panel)
│ └── icons/ app and tray icons │ └── icons/ app and tray icons
├── gnome-shell-extension/ tray/popup integration for GNOME ├── gnome-shell-extension/ tray/popup integration for GNOME
├── cinnamon-extension/ popup placement for Cinnamon Wayland
├── kwin-script/ popup placement for KDE Wayland ├── kwin-script/ popup placement for KDE Wayland
├── dev.sh development mode (tauri dev) ├── dev.sh development mode (tauri dev)
── build.sh macOS release build (.app bundle + DMG) ── build.sh release build for the current OS (see below)
├── deploy-linux.sh install the freshest local bundle (rpm/deb)
└── deploy-macos.sh install the fresh .app into ~/Applications
``` ```
Process roles from one binary: The three process roles that binary takes are described under [Architecture](#architecture).
- **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.
## Tooling ## Tooling
| Area | Stack | | Area | Stack |
|-----------|-------| |-----------|-------|
| Shell | Tauri 2 (Rust) | | Shell | Tauri 2 (Rust) |
| Frontend | Vue 3, TypeScript, Vite | | Frontend | Vue 3, TypeScript, Vite; vitest (happy-dom) for the panel views |
| Terminal | xterm.js, portable-pty | | Terminal | xterm.js, portable-pty |
| macOS | objc2 / objc2-app-kit (Dock icon, window focus, tray) | | macOS | objc2 / objc2-app-kit (Dock icon, window focus, tray) |
| Linux | GTK 3 / WebKitGTK, ksni (StatusNotifierItem), zbus (D-Bus) | | Linux | GTK 3 / WebKitGTK, ksni (StatusNotifierItem), zbus (D-Bus) |
@@ -109,12 +185,16 @@ Running projects are detected through their terminal processes (`pgrep` for `--t
```sh ```sh
./dev.sh # tauri dev with hot reload ./dev.sh # tauri dev with hot reload
./build.sh # macOS release build; bundle lands in src-tauri/target/release/bundle/macos.noindex/ ./build.sh # release build for the current OS
./deploy-macos.sh # …then install it (pick the script for your OS)
``` ```
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`. `build.sh` picks the bundles by platform: macOS `.app` + DMG, Linux deb + rpm. Installing is the job of the matching deploy script:
`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`. - **`deploy-macos.sh`** — installs the fresh bundle into `~/Applications` (replacing the previous copy) and registers it with Launch Services. The build output is then moved into `bundle/macos.noindex/` and unregistered, so Spotlight and the launcher only ever find the installed copy. It refuses to run while `ai-control` is still running.
- **`deploy-linux.sh`** — installs the freshest local bundle via `rpm`/`dpkg`.
Build dependencies on Linux: `webkit2gtk-4.1`, `libgtk-3`, `libayatana-appindicator3`, `librsvg2`, `patchelf`.
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. 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.
@@ -123,7 +203,7 @@ Prerequisites: Rust (stable), Node.js/npm. `dev.sh`/`build.sh` expect `CARGO_HOM
``` ```
~/.config/ai-control/ ~/.config/ai-control/
├── settings.json app settings (see below) ├── settings.json app settings (see below)
├── projects.json project registry: project id → directory ├── projects.json registry: project UUID → directory
│ + pool assignment (machine-local) │ + pool assignment (machine-local)
└── pools/<pool>/ one Claude config directory per pool └── pools/<pool>/ one Claude config directory per pool
└── pool.json name + credential type └── pool.json name + credential type
@@ -131,9 +211,9 @@ Prerequisites: Rust (stable), Node.js/npm. `dev.sh`/`build.sh` expect `CARGO_HOM
<project directory>/ any path, mapped via the registry <project directory>/ any path, mapped via the registry
├── .claude/ Claude Code project configuration ├── .claude/ Claude Code project configuration
└── .ai-control/ syncs with the project └── .ai-control/ syncs with the project
├── config.json project id (UUID) + display name, ├── config.json project id (UUID), display name,
│ terminal settings, panel archive │ terminal settings (theme, icon, title),
directory (archiveHome) archive home (archiveHome)
└── icon.png project icon └── icon.png project icon
``` ```
@@ -145,13 +225,16 @@ Prerequisites: Rust (stable), Node.js/npm. `dev.sh`/`build.sh` expect `CARGO_HOM
| `syncOnSessionEnd` | `false` | After a session ends, commit and push the Git repository containing the project. | | `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. | | `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). | | `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). | | `spellcheckLang` | `de` | Spell-check language of the text panel (per-text override in the panel). |
## Claude and Anthropic licensing ## Claude and Anthropic
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. Two things to be clear about:
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. - **No connection to Anthropic.** aICentral is an independent open-source project — not affiliated with, sponsored by, endorsed by, or in any way associated with Anthropic. Claude and Claude Code are trademarks of Anthropic and are named here solely to describe what this app works with.
- **No effect on your licence or your usage.** aICentral organises config directories; it does not touch, extend, replace, or circumvent Anthropic's terms, and it grants no access of its own. Whatever you may do with Claude Code, you may do through aICentral — no more, no less. Every request runs under your own login against your own subscription or API key, exactly as it would without this app.
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. 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.
@@ -0,0 +1,69 @@
/* Cinnamon-Extension (CJS, Muffin/Meta-0)
* ai-control-popup@local
*
* Das Tray-Icon kommt unter Cinnamon über SNI (xapp), die App zeigt das
* Popup selbst. Die Extension ergänzt, was der Client nicht kann:
* - Wayland: set_position aus dem Client ist wirkungslos — die Extension
* schiebt das frisch gemappte Popup an den Mauszeiger (der beim Klick auf
* dem Icon steht), mit derselben Klemm-Logik wie position_popup in app.rs.
* - X11: Muffins Focus-Stealing-Prevention verwirft das present() der App —
* ohne initialen Fokus schließt das Blur-Verstecken nie. Die Extension
* fokussiert das Popup beim Map aus dem Compositor heraus (platziert wird
* client-seitig über die SNI-Activate-Koordinaten).
*/
const Meta = imports.gi.Meta;
const WIN_TITLE = 'ai-control-popup';
let mapId = 0;
function _place(win) {
const [px, py] = global.get_pointer();
const rect = win.get_frame_rect();
// Work-Area statt Monitor: die Panel-Struts bleiben frei, das Popup sitzt
// über der Leiste statt darauf.
const wa = win.get_work_area_current_monitor();
const maxX = wa.x + wa.width - rect.width;
const maxY = wa.y + wa.height - rect.height;
const x = Math.min(Math.max(px - rect.width, wa.x), Math.max(maxX, wa.x));
const y = Math.min(Math.max(py, wa.y), Math.max(maxY, wa.y));
win.move_frame(true, x, y);
}
// Beim Mappen unsichtbar schalten und vor dem nächsten Redraw positionieren —
// der erste sichtbare Frame sitzt schon am Zielort (Muster wie in der
// GNOME-Extension, dort über global.compositor.get_laters()).
function _placeBeforeRedraw(actor, win) {
actor.remove_all_transitions();
actor.opacity = 0;
Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
const rect = win.get_frame_rect();
// Noch keine echte Größe -> nächster Redraw, bis positionierbar.
if (!rect || rect.width <= 1) return true;
_place(win);
actor.opacity = 255;
return false;
});
}
function init(_metadata) {}
function enable() {
mapId = global.window_manager.connect('map', (_wm, actor) => {
const win = actor.meta_window;
if (!win || win.get_title() !== WIN_TITLE) return;
if (Meta.is_wayland_compositor()) {
_placeBeforeRedraw(actor, win);
} else {
win.activate(global.get_current_time());
}
});
}
function disable() {
if (mapId) {
global.window_manager.disconnect(mapId);
mapId = 0;
}
}
@@ -0,0 +1,7 @@
{
"uuid": "ai-control-popup@local",
"name": "ai-control popup placement",
"description": "ai-control-Tray-Popup: platziert es unter Wayland am Mauszeiger und gibt ihm unter X11 den initialen Fokus (Focus-Stealing-Prevention).",
"cinnamon-version": ["6.0", "6.2", "6.4", "6.6"],
"version": 1
}
+27 -4
View File
@@ -1,15 +1,37 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# macOS-Deploy nach build.sh: Dev-Bundle aus Launch Services austragen und in # macOS-Deploy nach build.sh: das frisch gebaute Bundle nach ~/Applications
# ein .noindex-Verzeichnis schieben — Spotlight indiziert .noindex nicht, damit # installieren. Das Dev-Bundle wandert danach aus Launch Services heraus in ein
# erscheint im Starter/Spotlight nur die installierte Kopie. Installiert wird # .noindex-Verzeichnis — Spotlight indiziert .noindex nicht, damit erscheint im
# über das DMG (Pfad am Ende). # Starter/Spotlight nur die installierte Kopie.
set -euo pipefail set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUNDLE="$DIR/src-tauri/target/release/bundle/macos" BUNDLE="$DIR/src-tauri/target/release/bundle/macos"
NOINDEX="$DIR/src-tauri/target/release/bundle/macos.noindex" NOINDEX="$DIR/src-tauri/target/release/bundle/macos.noindex"
TARGET="$HOME/Applications"
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister
if [[ ! -d "$BUNDLE/ai-control.app" ]]; then
echo "kein frisches Bundle unter $BUNDLE — erst ./build.sh laufen lassen" >&2
exit 1
fi
# Eine laufende Instanz hält Dateien im Bundle offen; das Ersetzen ergäbe eine
# halb überschriebene App.
if pgrep -qx ai-control; then
echo "ai-control läuft — erst beenden, dann erneut deployen" >&2
exit 1
fi
# Installieren: alte Kopie ganz weg (ditto führt sonst Reste alter Builds
# fort), dann das neue Bundle hin und in Launch Services eintragen.
mkdir -p "$TARGET"
"$LSREGISTER" -u "$TARGET/ai-control.app" || true
rm -rf "$TARGET/ai-control.app"
ditto "$BUNDLE/ai-control.app" "$TARGET/ai-control.app"
"$LSREGISTER" -f "$TARGET/ai-control.app"
# lsregister -u meldet -10814, wenn das Bundle nicht registriert ist — # lsregister -u meldet -10814, wenn das Bundle nicht registriert ist —
# erwartetes Ergebnis, kein Fehler. # erwartetes Ergebnis, kein Fehler.
"$LSREGISTER" -u "$BUNDLE/ai-control.app" || true "$LSREGISTER" -u "$BUNDLE/ai-control.app" || true
@@ -18,4 +40,5 @@ mkdir -p "$NOINDEX"
mv "$BUNDLE/ai-control.app" "$NOINDEX/" mv "$BUNDLE/ai-control.app" "$NOINDEX/"
"$LSREGISTER" -u "$NOINDEX/ai-control.app" || true "$LSREGISTER" -u "$NOINDEX/ai-control.app" || true
echo "installiert: $TARGET/ai-control.app"
echo "DMG: $(ls "$DIR"/src-tauri/target/release/bundle/dmg/*.dmg)" echo "DMG: $(ls "$DIR"/src-tauri/target/release/bundle/dmg/*.dmg)"
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ai-control</title> <title>aICentral</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ai-control", "name": "ai-control",
"private": true, "private": true,
"version": "0.2.0", "version": "0.3.0",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
+9 -411
View File
@@ -2,433 +2,31 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Dokument</title> <title data-i18n="panel.windowPanel">Panel</title>
<style>
/* Theme-Variablen — Defaults sind Mocha; applyTheme (themes.ts) überschreibt. */
:root {
--bg: #1e1e2e;
--surface: #181825;
--tile: #11111b;
--line: #313244;
--line-strong: #45475a;
--text: #cdd6f4;
--muted: #a6adc8;
--faint: #6c7086;
--accent: #89b4fa;
--ok: #a6e3a1;
--warn: #f9e2af;
--err: #f38ba8;
}
html,
body {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
}
/* Feiner Fensterrahmen fürs dekorationslose Fenster (nur Linux). */
:root[data-platform="other"] body {
box-sizing: border-box;
border: 1px solid var(--line);
}
.panel-head {
flex: none;
display: flex;
flex-direction: column;
gap: 3px;
padding: 7px 8px 9px 14px;
background: var(--surface);
border-bottom: 1px solid var(--line);
font:
500 12px/1 -apple-system,
system-ui,
sans-serif;
user-select: none;
-webkit-user-select: none;
}
.panel-titlerow {
display: flex;
align-items: center;
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: var(--surface);
border-bottom: 1px solid var(--line);
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: var(--line);
}
.winbtn:hover svg {
opacity: 1;
}
.winbtn.close:hover {
background: var(--err);
color: var(--tile);
}
/* 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: var(--line-strong);
margin: 0 6px;
align-self: center;
}
.panel-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
}
.panel-title {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
}
.panel-title[contenteditable="true"] {
overflow: visible;
text-overflow: clip;
outline: none;
color: inherit;
cursor: text;
}
.panel-btn {
border: none;
background: transparent;
color: var(--muted);
cursor: default;
border-radius: 6px;
padding: 6px 9px;
font: inherit;
display: flex;
align-items: center;
}
.panel-btn:hover {
background: var(--line);
color: var(--text);
}
.panel-btn.copied {
color: var(--ok);
}
.panel-btn.error {
color: var(--err);
}
.panel-btn svg {
display: block;
stroke: currentColor;
stroke-width: 1.3;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.panel-btn.active {
background: var(--line);
color: var(--text);
}
/* Aktiver Tab: Akzentfarbe + Unterstrich statt nur Flächenwechsel. */
.panel-tabs .panel-btn.active {
background: transparent;
color: var(--accent);
border-radius: 0;
box-shadow: inset 0 -2px 0 currentColor;
}
.panel-btn.changed {
color: var(--warn);
}
.panel-editor {
flex: 1;
min-height: 0;
resize: none;
border: none;
outline: none;
padding: 16px 20px;
background: transparent;
color: inherit;
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.55;
}
.panel-editor[hidden] {
display: none;
}
.panel-tabs {
display: flex;
gap: 2px;
}
.panel-lang {
appearance: none;
-webkit-appearance: none;
margin-right: auto;
background: transparent;
color: var(--muted);
border: none;
border-radius: 6px;
padding: 6px 9px;
font: inherit;
cursor: default;
}
.panel-lang:hover {
background: var(--line);
color: var(--text);
}
#panel-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 20px;
user-select: text;
-webkit-user-select: text;
cursor: text;
font-size: 13px;
line-height: 1.55;
}
#panel-content::-webkit-scrollbar {
width: 8px;
}
#panel-content::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 4px;
}
#panel-content.raw {
white-space: pre-wrap;
word-break: break-word;
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 12px;
}
#panel-content.md h1,
#panel-content.md h2,
#panel-content.md h3 {
line-height: 1.25;
margin: 1em 0 0.4em;
}
#panel-content.md h1 {
font-size: 1.4em;
}
#panel-content.md h2 {
font-size: 1.2em;
}
#panel-content.md h3 {
font-size: 1.05em;
}
#panel-content.md :first-child {
margin-top: 0;
}
#panel-content.md p,
#panel-content.md ul,
#panel-content.md ol,
#panel-content.md pre,
#panel-content.md blockquote {
margin: 0 0 0.7em;
}
#panel-content.md code {
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 0.9em;
background: var(--line);
border-radius: 4px;
padding: 1px 5px;
}
#panel-content.md pre {
background: var(--tile);
border-radius: 8px;
padding: 12px 14px;
overflow: auto;
}
#panel-content.md pre code {
background: none;
padding: 0;
}
#panel-content.md blockquote {
border-left: 3px solid var(--line);
padding-left: 12px;
color: var(--muted);
}
#panel-content.md a {
color: var(--accent);
}
.panel-btn[hidden],
.panel-lang[hidden],
#panel-content[hidden] {
display: none;
}
</style>
</head> </head>
<body> <body>
<div class="panel-topbar" data-tauri-drag-region> <div class="panel-topbar" data-tauri-drag-region>
<div class="panel-tabs" id="panel-tabs"> <!-- Tabs kommen aus der Modul-Registry (panel-wiring). -->
<button class="panel-btn" data-mode="commands" title="Befehls-History">Befehle</button> <div class="panel-tabs" id="panel-tabs"></div>
<span class="tab-sep"></span> <button class="winbtn" id="panel-dock" data-i18n-title="panel.dock">
<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> <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>
<button class="winbtn osctl" id="win-min" aria-label="Minimieren"> <button class="winbtn osctl" id="win-min" data-i18n-aria="panel.minimize">
<svg width="12" height="12" viewBox="0 0 12 12"><line x1="2" y1="6.5" x2="10" y2="6.5" /></svg> <svg width="12" height="12" viewBox="0 0 12 12"><line x1="2" y1="6.5" x2="10" y2="6.5" /></svg>
</button> </button>
<button class="winbtn osctl" id="win-max" aria-label="Maximieren"> <button class="winbtn osctl" id="win-max" data-i18n-aria="panel.maximize">
<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> <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>
<button class="winbtn close" id="panel-close" title="Panel schließen"> <button class="winbtn close" id="panel-close" data-i18n-title="panel.closePanel">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg> <svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" /></svg>
</button> </button>
</div> </div>
<!-- Modul-Container (<mode>-content) reihen sich hinter der Kopfzeile ein. -->
<div class="panel-head"> <div class="panel-head">
<div class="panel-titlerow" data-tauri-drag-region> <div class="panel-titlerow" data-tauri-drag-region>
<span class="panel-title">Dokument</span> <span class="panel-title"></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 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>
<option value="fr">FR</option>
<option value="es">ES</option>
<option value="it">IT</option>
<option value="nl">NL</option>
</select>
<button class="panel-btn draft-only" id="panel-wiki-jump" title="Im Wiki öffnen">
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M8 3.5C6.6 2.4 4.6 2 2.5 2v11c2.1 0 4.1.4 5.5 1.5 1.4-1.1 3.4-1.5 5.5-1.5V2c-2.1 0-4.1.4-5.5 1.5z" /><path d="M8 3.5v11" /></svg>
</button>
<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 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 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>
</div> </div>
</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 id="resize-grips" aria-hidden="true">
<div class="grip n" data-dir="North"></div> <div class="grip n" data-dir="North"></div>
<div class="grip s" data-dir="South"></div> <div class="grip s" data-dir="South"></div>
+1 -8
View File
@@ -2,14 +2,7 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Projekte</title> <title data-i18n="panel.windowProjects">Projekte</title>
<style>
html,
body {
margin: 0;
background: transparent;
}
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+27 -76
View File
@@ -19,18 +19,6 @@ dependencies = [
"version_check", "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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -42,7 +30,7 @@ dependencies = [
[[package]] [[package]]
name = "ai-control" name = "ai-control"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"glib", "glib",
@@ -54,8 +42,8 @@ dependencies = [
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation", "objc2-foundation",
"portable-pty", "portable-pty",
"pulldown-cmark",
"rfd 0.15.4", "rfd 0.15.4",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -1256,18 +1244,6 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -1803,16 +1779,7 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [ dependencies = [
"ahash 0.7.8", "ahash",
]
[[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]] [[package]]
@@ -1830,15 +1797,6 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 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]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@@ -2367,17 +2325,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -3172,6 +3119,24 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
dependencies = [
"bitflags 2.13.0",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.29" version = "0.1.29"
@@ -3465,20 +3430,6 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "rust_decimal" name = "rust_decimal"
version = "1.42.1" version = "1.42.1"
@@ -4930,6 +4881,12 @@ dependencies = [
"unic-common", "unic-common",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -5009,12 +4966,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "vec_map" name = "vec_map"
version = "0.8.2" version = "0.8.2"
+6 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ai-control" name = "ai-control"
version = "0.2.0" version = "0.3.0"
description = "Pool- und Session-Verwaltung für Claude Code" description = "Pool- und Session-Verwaltung für Claude Code"
authors = ["marcus.hinz"] authors = ["marcus.hinz"]
license = "MIT" license = "MIT"
@@ -34,8 +34,8 @@ base64 = "0.22"
sha2 = "0.10" sha2 = "0.10"
portable-pty = "0.9" portable-pty = "0.9"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
# Volltext-Suche übers Panel-Archiv (SQLite FTS5, in-memory aus dem Baum gebaut). # Markdown→HTML beim Archivieren in Trilium (Notizen sind HTML).
rusqlite = { version = "0.32", features = ["bundled"] } pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] } keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
@@ -46,6 +46,9 @@ rfd = { version = "0.15", default-features = false, features = ["gtk3"] }
ksni = "0.1" ksni = "0.1"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
# Der Installations-Dialog läuft vor dem Tauri-Setup, also ohne AppHandle und
# damit ohne das Dialog-Plugin. Unter macOS braucht rfd kein Backend-Feature.
rfd = { version = "0.15", default-features = false }
objc2 = "0.6" objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder", "NSRunningApplication"] } objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder", "NSRunningApplication"] }
objc2-foundation = { version = "0.3", features = ["NSString"] } objc2-foundation = { version = "0.3", features = ["NSString"] }
+14 -16
View File
@@ -18,6 +18,8 @@ fn main() {
"list_pools", "list_pools",
"create_oauth_pool", "create_oauth_pool",
"create_apikey_pool", "create_apikey_pool",
"create_reference_pool",
"default_config_dir",
"rename_pool", "rename_pool",
"delete_pool", "delete_pool",
"assign_pool", "assign_pool",
@@ -25,8 +27,6 @@ fn main() {
"set_terminal_config", "set_terminal_config",
"project_icon", "project_icon",
"pool_label", "pool_label",
"todo_state",
"set_todo",
"usage_stats", "usage_stats",
"stop_project", "stop_project",
"restart_project", "restart_project",
@@ -41,27 +41,25 @@ fn main() {
"oauth_login", "oauth_login",
"keychain_status", "keychain_status",
"set_apikey", "set_apikey",
"panel_archive_dir_cmd", "trilium_status",
"panel_archive_cmd", "trilium_setup",
"set_archive_home_cmd", "trilium_connect",
"clear_archive_home_cmd", "trilium_open",
"archive_docs_cmd", "trilium_disable",
"reveal_path_cmd",
"spellcheck_lang",
"open_terminal", "open_terminal",
"term_start", "term_start",
"term_log", "term_log",
"term_write", "term_write",
"term_resize", "term_resize",
"panel_read", "buffer_read",
"commands_read", "enabled_modules",
"module_registry",
"set_module",
"commands_delete", "commands_delete",
"panel_set", "todos_delete",
"search_read", "todos_add",
"todos_update",
"search_run", "search_run",
"panel_load",
"wiki_read",
"wiki_open",
"open_panel_window", "open_panel_window",
])), ])),
) )
+10 -7
View File
@@ -30,14 +30,14 @@
"allow-list-pools", "allow-list-pools",
"allow-create-oauth-pool", "allow-create-oauth-pool",
"allow-create-apikey-pool", "allow-create-apikey-pool",
"allow-create-reference-pool",
"allow-default-config-dir",
"allow-rename-pool", "allow-rename-pool",
"allow-delete-pool", "allow-delete-pool",
"allow-assign-pool", "allow-assign-pool",
"allow-unassign-pool", "allow-unassign-pool",
"allow-set-terminal-config", "allow-set-terminal-config",
"allow-project-icon", "allow-project-icon",
"allow-todo-state",
"allow-set-todo",
"allow-usage-stats", "allow-usage-stats",
"allow-stop-project", "allow-stop-project",
"allow-restart-project", "allow-restart-project",
@@ -48,11 +48,14 @@
"allow-oauth-login", "allow-oauth-login",
"allow-keychain-status", "allow-keychain-status",
"allow-set-apikey", "allow-set-apikey",
"allow-panel-archive-dir-cmd",
"allow-set-archive-home-cmd",
"allow-clear-archive-home-cmd",
"allow-archive-docs-cmd",
"allow-delete-preview", "allow-delete-preview",
"allow-delete-project-scoped" "allow-delete-project-scoped",
"allow-module-registry",
"allow-set-module",
"allow-trilium-status",
"allow-trilium-setup",
"allow-trilium-connect",
"allow-trilium-open",
"allow-trilium-disable"
] ]
} }
+6 -11
View File
@@ -20,20 +20,15 @@
"autostart:default", "autostart:default",
"dialog:default", "dialog:default",
"clipboard-manager:allow-write-text", "clipboard-manager:allow-write-text",
"allow-buffer-read",
"allow-commands-delete", "allow-commands-delete",
"allow-commands-read", "allow-enabled-modules",
"allow-list-projects", "allow-list-projects",
"allow-panel-archive-cmd", "allow-trilium-open",
"allow-panel-archive-dir-cmd",
"allow-panel-load",
"allow-panel-read",
"allow-panel-set",
"allow-reveal-path-cmd",
"allow-search-read",
"allow-search-run", "allow-search-run",
"allow-spellcheck-lang",
"allow-term-log", "allow-term-log",
"allow-wiki-open", "allow-todos-add",
"allow-wiki-read" "allow-todos-delete",
"allow-todos-update"
] ]
} }
+6 -11
View File
@@ -20,28 +20,23 @@
"autostart:default", "autostart:default",
"dialog:default", "dialog:default",
"clipboard-manager:allow-write-text", "clipboard-manager:allow-write-text",
"allow-buffer-read",
"allow-commands-delete", "allow-commands-delete",
"allow-commands-read", "allow-enabled-modules",
"allow-list-projects", "allow-list-projects",
"allow-open-panel-window", "allow-open-panel-window",
"allow-panel-archive-cmd", "allow-trilium-open",
"allow-panel-archive-dir-cmd",
"allow-panel-load",
"allow-panel-read",
"allow-panel-set",
"allow-pool-label", "allow-pool-label",
"allow-project-icon", "allow-project-icon",
"allow-reveal-path-cmd",
"allow-search-read",
"allow-search-run", "allow-search-run",
"allow-set-terminal-font-size", "allow-set-terminal-font-size",
"allow-spellcheck-lang",
"allow-term-log", "allow-term-log",
"allow-term-resize", "allow-term-resize",
"allow-term-start", "allow-term-start",
"allow-term-write", "allow-term-write",
"allow-terminal-font-size", "allow-terminal-font-size",
"allow-wiki-open", "allow-todos-add",
"allow-wiki-read" "allow-todos-delete",
"allow-todos-update"
] ]
} }
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-create-doc"
description = "Enables the archive_create_doc command without any pre-configured scope."
commands.allow = ["archive_create_doc"]
[[permission]]
identifier = "deny-archive-create-doc"
description = "Denies the archive_create_doc command without any pre-configured scope."
commands.deny = ["archive_create_doc"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-create-folder"
description = "Enables the archive_create_folder command without any pre-configured scope."
commands.allow = ["archive_create_folder"]
[[permission]]
identifier = "deny-archive-create-folder"
description = "Denies the archive_create_folder command without any pre-configured scope."
commands.deny = ["archive_create_folder"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-delete"
description = "Enables the archive_delete command without any pre-configured scope."
commands.allow = ["archive_delete"]
[[permission]]
identifier = "deny-archive-delete"
description = "Denies the archive_delete command without any pre-configured scope."
commands.deny = ["archive_delete"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-docs-cmd"
description = "Enables the archive_docs_cmd command without any pre-configured scope."
commands.allow = ["archive_docs_cmd"]
[[permission]]
identifier = "deny-archive-docs-cmd"
description = "Denies the archive_docs_cmd command without any pre-configured scope."
commands.deny = ["archive_docs_cmd"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-move"
description = "Enables the archive_move command without any pre-configured scope."
commands.allow = ["archive_move"]
[[permission]]
identifier = "deny-archive-move"
description = "Denies the archive_move command without any pre-configured scope."
commands.deny = ["archive_move"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-move-folder"
description = "Enables the archive_move_folder command without any pre-configured scope."
commands.allow = ["archive_move_folder"]
[[permission]]
identifier = "deny-archive-move-folder"
description = "Denies the archive_move_folder command without any pre-configured scope."
commands.deny = ["archive_move_folder"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-archive-rename"
description = "Enables the archive_rename command without any pre-configured scope."
commands.allow = ["archive_rename"]
[[permission]]
identifier = "deny-archive-rename"
description = "Denies the archive_rename command without any pre-configured scope."
commands.deny = ["archive_rename"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-buffer-read"
description = "Enables the buffer_read command without any pre-configured scope."
commands.allow = ["buffer_read"]
[[permission]]
identifier = "deny-buffer-read"
description = "Denies the buffer_read command without any pre-configured scope."
commands.deny = ["buffer_read"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-clear-archive-home-cmd"
description = "Enables the clear_archive_home_cmd command without any pre-configured scope."
commands.allow = ["clear_archive_home_cmd"]
[[permission]]
identifier = "deny-clear-archive-home-cmd"
description = "Denies the clear_archive_home_cmd command without any pre-configured scope."
commands.deny = ["clear_archive_home_cmd"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-create-reference-pool"
description = "Enables the create_reference_pool command without any pre-configured scope."
commands.allow = ["create_reference_pool"]
[[permission]]
identifier = "deny-create-reference-pool"
description = "Denies the create_reference_pool command without any pre-configured scope."
commands.deny = ["create_reference_pool"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-default-config-dir"
description = "Enables the default_config_dir command without any pre-configured scope."
commands.allow = ["default_config_dir"]
[[permission]]
identifier = "deny-default-config-dir"
description = "Denies the default_config_dir command without any pre-configured scope."
commands.deny = ["default_config_dir"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-delete-project"
description = "Enables the delete_project command without any pre-configured scope."
commands.allow = ["delete_project"]
[[permission]]
identifier = "deny-delete-project"
description = "Denies the delete_project command without any pre-configured scope."
commands.deny = ["delete_project"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-enabled-modules"
description = "Enables the enabled_modules command without any pre-configured scope."
commands.allow = ["enabled_modules"]
[[permission]]
identifier = "deny-enabled-modules"
description = "Denies the enabled_modules command without any pre-configured scope."
commands.deny = ["enabled_modules"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-module-registry"
description = "Enables the module_registry command without any pre-configured scope."
commands.allow = ["module_registry"]
[[permission]]
identifier = "deny-module-registry"
description = "Denies the module_registry command without any pre-configured scope."
commands.deny = ["module_registry"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-panel-archive-cmd"
description = "Enables the panel_archive_cmd command without any pre-configured scope."
commands.allow = ["panel_archive_cmd"]
[[permission]]
identifier = "deny-panel-archive-cmd"
description = "Denies the panel_archive_cmd command without any pre-configured scope."
commands.deny = ["panel_archive_cmd"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-panel-archive-dir-cmd"
description = "Enables the panel_archive_dir_cmd command without any pre-configured scope."
commands.allow = ["panel_archive_dir_cmd"]
[[permission]]
identifier = "deny-panel-archive-dir-cmd"
description = "Denies the panel_archive_dir_cmd command without any pre-configured scope."
commands.deny = ["panel_archive_dir_cmd"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-panel-load"
description = "Enables the panel_load command without any pre-configured scope."
commands.allow = ["panel_load"]
[[permission]]
identifier = "deny-panel-load"
description = "Denies the panel_load command without any pre-configured scope."
commands.deny = ["panel_load"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-panel-set"
description = "Enables the panel_set command without any pre-configured scope."
commands.allow = ["panel_set"]
[[permission]]
identifier = "deny-panel-set"
description = "Denies the panel_set command without any pre-configured scope."
commands.deny = ["panel_set"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-remove-project"
description = "Enables the remove_project command without any pre-configured scope."
commands.allow = ["remove_project"]
[[permission]]
identifier = "deny-remove-project"
description = "Denies the remove_project command without any pre-configured scope."
commands.deny = ["remove_project"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-reveal-path-cmd"
description = "Enables the reveal_path_cmd command without any pre-configured scope."
commands.allow = ["reveal_path_cmd"]
[[permission]]
identifier = "deny-reveal-path-cmd"
description = "Denies the reveal_path_cmd command without any pre-configured scope."
commands.deny = ["reveal_path_cmd"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-set-archive-home-cmd"
description = "Enables the set_archive_home_cmd command without any pre-configured scope."
commands.allow = ["set_archive_home_cmd"]
[[permission]]
identifier = "deny-set-archive-home-cmd"
description = "Denies the set_archive_home_cmd command without any pre-configured scope."
commands.deny = ["set_archive_home_cmd"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-set-module"
description = "Enables the set_module command without any pre-configured scope."
commands.allow = ["set_module"]
[[permission]]
identifier = "deny-set-module"
description = "Denies the set_module command without any pre-configured scope."
commands.deny = ["set_module"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-spellcheck-lang"
description = "Enables the spellcheck_lang command without any pre-configured scope."
commands.allow = ["spellcheck_lang"]
[[permission]]
identifier = "deny-spellcheck-lang"
description = "Denies the spellcheck_lang command without any pre-configured scope."
commands.deny = ["spellcheck_lang"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-todos-add"
description = "Enables the todos_add command without any pre-configured scope."
commands.allow = ["todos_add"]
[[permission]]
identifier = "deny-todos-add"
description = "Denies the todos_add command without any pre-configured scope."
commands.deny = ["todos_add"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-todos-delete"
description = "Enables the todos_delete command without any pre-configured scope."
commands.allow = ["todos_delete"]
[[permission]]
identifier = "deny-todos-delete"
description = "Denies the todos_delete command without any pre-configured scope."
commands.deny = ["todos_delete"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-todos-update"
description = "Enables the todos_update command without any pre-configured scope."
commands.allow = ["todos_update"]
[[permission]]
identifier = "deny-todos-update"
description = "Denies the todos_update command without any pre-configured scope."
commands.deny = ["todos_update"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-trilium-connect"
description = "Enables the trilium_connect command without any pre-configured scope."
commands.allow = ["trilium_connect"]
[[permission]]
identifier = "deny-trilium-connect"
description = "Denies the trilium_connect command without any pre-configured scope."
commands.deny = ["trilium_connect"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-trilium-disable"
description = "Enables the trilium_disable command without any pre-configured scope."
commands.allow = ["trilium_disable"]
[[permission]]
identifier = "deny-trilium-disable"
description = "Denies the trilium_disable command without any pre-configured scope."
commands.deny = ["trilium_disable"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-trilium-open"
description = "Enables the trilium_open command without any pre-configured scope."
commands.allow = ["trilium_open"]
[[permission]]
identifier = "deny-trilium-open"
description = "Denies the trilium_open command without any pre-configured scope."
commands.deny = ["trilium_open"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-trilium-setup"
description = "Enables the trilium_setup command without any pre-configured scope."
commands.allow = ["trilium_setup"]
[[permission]]
identifier = "deny-trilium-setup"
description = "Denies the trilium_setup command without any pre-configured scope."
commands.deny = ["trilium_setup"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-trilium-status"
description = "Enables the trilium_status command without any pre-configured scope."
commands.allow = ["trilium_status"]
[[permission]]
identifier = "deny-trilium-status"
description = "Denies the trilium_status command without any pre-configured scope."
commands.deny = ["trilium_status"]
@@ -1,11 +0,0 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-wiki-open"
description = "Enables the wiki_open command without any pre-configured scope."
commands.allow = ["wiki_open"]
[[permission]]
identifier = "deny-wiki-open"
description = "Denies the wiki_open command without any pre-configured scope."
commands.deny = ["wiki_open"]
+25 -20
View File
@@ -61,15 +61,21 @@ pub(crate) fn show_popup(app: &tauri::AppHandle, anchor: Anchor) {
}; };
let _ = w.set_position(tauri::PhysicalPosition::new(x, y)); let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
} }
// SNI (KDE/XFCE/Cinnamon): Activate liefert keine brauchbaren // SNI (KDE/XFCE/Cinnamon): Activate-Koordinaten, wenn der Host welche
// Koordinaten, aber der Zeiger sitzt beim Klick auf dem Icon. // mitgibt (Cinnamon); sonst Zeigerposition — der Zeiger sitzt beim
Anchor::Cursor => { // Klick auf dem Icon. Unter Wayland liefert cursor_position (0,0).
Anchor::Click { x, y } => {
#[cfg(not(target_os = "linux"))]
let _ = (x, y);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
// Gelöst wird die Sperre vom ersten Focused(true) im Event-Handler. // Gelöst wird die Sperre vom ersten Focused(true) im Event-Handler.
if let Some(g) = win.try_state::<PopupBlurGuard>() { if let Some(g) = win.try_state::<PopupBlurGuard>() {
g.0.store(true, std::sync::atomic::Ordering::SeqCst); g.0.store(true, std::sync::atomic::Ordering::SeqCst);
} }
if (x, y) != (0, 0) {
position_popup(&w, x, y);
} else {
match win.cursor_position() { match win.cursor_position() {
Ok(p) => position_popup(&w, p.x as i32, p.y as i32), Ok(p) => position_popup(&w, p.x as i32, p.y as i32),
Err(_) => { Err(_) => {
@@ -78,6 +84,7 @@ pub(crate) fn show_popup(app: &tauri::AppHandle, anchor: Anchor) {
} }
} }
} }
}
// GNOME-Extension/KWin: der Compositor positioniert selbst. // GNOME-Extension/KWin: der Compositor positioniert selbst.
Anchor::Managed => {} Anchor::Managed => {}
} }
@@ -136,7 +143,7 @@ pub(crate) fn main_builder() -> tauri::Builder<tauri::Wry> {
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess // Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt. // (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default()) let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
.title("ai-control") .title("aICentral")
.inner_size(800.0, 600.0) .inner_size(800.0, 600.0)
// Fenster-Icon (_NET_WM_ICON) — deckt X11-DEs wie XFCE direkt ab, // Fenster-Icon (_NET_WM_ICON) — deckt X11-DEs wie XFCE direkt ab,
// unabhängig von der .desktop-Zuordnung; auf Windows das Titel-/Taskbar-Icon. // unabhängig von der .desktop-Zuordnung; auf Windows das Titel-/Taskbar-Icon.
@@ -255,6 +262,8 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
commands::remove_work_dir, commands::remove_work_dir,
commands::list_pools, commands::list_pools,
commands::create_oauth_pool, commands::create_oauth_pool,
commands::create_reference_pool,
commands::default_config_dir,
commands::create_apikey_pool, commands::create_apikey_pool,
commands::rename_pool, commands::rename_pool,
commands::delete_pool, commands::delete_pool,
@@ -263,8 +272,6 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
commands::set_terminal_config, commands::set_terminal_config,
commands::project_icon, commands::project_icon,
commands::pool_label, commands::pool_label,
commands::todo_state,
commands::set_todo,
commands::usage_stats, commands::usage_stats,
commands::stop_project, commands::stop_project,
commands::restart_project, commands::restart_project,
@@ -279,27 +286,25 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
commands::oauth_login, commands::oauth_login,
commands::keychain_status, commands::keychain_status,
commands::set_apikey, commands::set_apikey,
commands::panel_archive_dir_cmd, commands::trilium_status,
commands::panel_archive_cmd, commands::trilium_setup,
commands::set_archive_home_cmd, commands::trilium_connect,
commands::clear_archive_home_cmd, commands::trilium_open,
commands::archive_docs_cmd, commands::trilium_disable,
commands::reveal_path_cmd, commands::enabled_modules,
commands::spellcheck_lang, commands::module_registry,
commands::set_module,
terminal::open_terminal, terminal::open_terminal,
terminal::term_start, terminal::term_start,
terminal::term_log, terminal::term_log,
terminal::term_write, terminal::term_write,
terminal::term_resize, terminal::term_resize,
terminal::panel_read, terminal::buffer_read,
terminal::commands_read,
terminal::commands_delete, terminal::commands_delete,
terminal::panel_set, terminal::todos_delete,
terminal::search_read, terminal::todos_add,
terminal::todos_update,
terminal::search_run, terminal::search_run,
terminal::panel_load,
terminal::wiki_read,
terminal::wiki_open,
terminal::open_panel_window terminal::open_panel_window
] ]
} }
+154 -70
View File
@@ -3,14 +3,12 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::domain::archive::{
archive_panel_content, project_archive_home, require_archive_home, ArchiveMeta,
};
use crate::domain::credentials::keychain_service; use crate::domain::credentials::keychain_service;
use crate::domain::paths::Paths; use crate::domain::paths::Paths;
use crate::domain::pool::{ use crate::domain::pool::{
create_apikey_pool_in, create_oauth_pool_in, delete_pool_in, ensure_oauth_pool, create_apikey_pool_in, create_oauth_pool_in, create_reference_pool_in, delete_pool_in,
link_pool_runtime_in, list_pools_in, read_pool, rename_pool_in, set_apikey_in, PoolInfo, ensure_oauth_pool, link_pool_runtime_in, list_pools_in, offered_default_dir, read_pool,
rename_pool_in, set_apikey_in, PoolInfo,
}; };
use crate::domain::project::{ use crate::domain::project::{
add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_preview_in, add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_preview_in,
@@ -20,7 +18,6 @@ use crate::domain::project::{
DeletePreview, Project, TerminalConfig, DeletePreview, Project, TerminalConfig,
}; };
use crate::domain::settings; use crate::domain::settings;
use crate::domain::todo::{set_todo_in, todo_state_in};
use crate::domain::usage::{usage_stats_in, UsageRow}; use crate::domain::usage::{usage_stats_in, UsageRow};
use crate::platform::KeychainStore; use crate::platform::KeychainStore;
use crate::terminal; use crate::terminal;
@@ -40,7 +37,6 @@ pub(crate) fn create_project_full(
work_dir: Option<String>, work_dir: Option<String>,
create_work_dir: bool, create_work_dir: bool,
terminal: TerminalConfig, terminal: TerminalConfig,
todo: bool,
) -> Result<String, String> { ) -> Result<String, String> {
create_project_full_in( create_project_full_in(
&Paths::real(), &Paths::real(),
@@ -50,7 +46,6 @@ pub(crate) fn create_project_full(
work_dir.as_deref(), work_dir.as_deref(),
create_work_dir, create_work_dir,
terminal, terminal,
todo,
) )
} }
@@ -60,13 +55,19 @@ pub(crate) fn add_project(path: String) -> Result<(), String> {
add_project_in(&Paths::real(), &path) add_project_in(&Paths::real(), &path)
} }
/// Umbau-Sperre: eine laufende Session des Projekts zuerst beenden.
fn ensure_not_running(project: &str) -> Result<(), String> {
if is_running(project) {
let name = display_name_in(&Paths::real(), project)?;
return Err(format!("{name} läuft noch — erst beenden"));
}
Ok(())
}
/// Projektordner neu zuordnen; bei laufender Session gesperrt. /// Projektordner neu zuordnen; bei laufender Session gesperrt.
#[tauri::command] #[tauri::command]
pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> { pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> {
if is_running(&project) { ensure_not_running(&project)?;
let name = display_name_in(&Paths::real(), &project)?;
return Err(format!("{name} läuft noch — erst beenden"));
}
set_project_dir_in(&Paths::real(), &project, &dir) set_project_dir_in(&Paths::real(), &project, &dir)
} }
@@ -95,10 +96,7 @@ pub(crate) fn delete_project_scoped(
scope: String, scope: String,
delete_work_dirs: bool, delete_work_dirs: bool,
) -> Result<(), String> { ) -> Result<(), String> {
if is_running(&project) { ensure_not_running(&project)?;
let name = display_name_in(&Paths::real(), &project)?;
return Err(format!("{name} läuft noch — erst beenden"));
}
delete_project_scoped_in(&Paths::real(), &project, &scope, delete_work_dirs) delete_project_scoped_in(&Paths::real(), &project, &scope, delete_work_dirs)
} }
@@ -107,16 +105,6 @@ pub(crate) fn project_work_dirs(project: String) -> Result<Vec<String>, String>
project_work_dirs_in(&Paths::real(), &project) project_work_dirs_in(&Paths::real(), &project)
} }
#[tauri::command]
pub(crate) fn todo_state(project: String) -> Result<bool, String> {
todo_state_in(&Paths::real(), &project)
}
#[tauri::command]
pub(crate) fn set_todo(project: String, enabled: bool) -> Result<(), String> {
set_todo_in(&Paths::real(), &project, enabled)
}
#[tauri::command] #[tauri::command]
pub(crate) fn set_terminal_config( pub(crate) fn set_terminal_config(
project: String, project: String,
@@ -215,7 +203,21 @@ pub(crate) fn set_apikey(pool: String, key: String, allow_file: bool) -> Result<
#[tauri::command] #[tauri::command]
pub(crate) fn keychain_status(pool: String) -> Result<bool, String> { pub(crate) fn keychain_status(pool: String) -> Result<bool, String> {
crate::platform::oauth_keychain_exists(&keychain_service(&Paths::real(), &pool)) crate::platform::oauth_keychain_exists(&keychain_service(&Paths::real(), &pool)?)
}
/// Claudes Default-Verzeichnis, sofern vorhanden und noch nicht als Pool
/// eingebunden — die Pool-Anlage bietet es dann als fertigen Pool an.
#[tauri::command]
pub(crate) fn default_config_dir() -> Option<String> {
offered_default_dir(&Paths::real())
}
/// Pool, der auf ein bestehendes Config-Verzeichnis verweist (statt eines neu
/// angelegten). Der vorhandene Login dort bleibt gültig.
#[tauri::command]
pub(crate) fn create_reference_pool(name: String, dir: String) -> Result<String, String> {
create_reference_pool_in(&Paths::real(), &name, &dir)
} }
/// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag, /// Setzt einen oauth-Pool zurück: löscht den suffixierten Keychain-Eintrag,
@@ -225,6 +227,13 @@ pub(crate) fn keychain_status(pool: String) -> Result<bool, String> {
pub(crate) fn oauth_login(pool: String) -> Result<(), String> { pub(crate) fn oauth_login(pool: String) -> Result<(), String> {
let paths = Paths::real(); let paths = Paths::real();
ensure_oauth_pool(&paths, &pool)?; ensure_oauth_pool(&paths, &pool)?;
// Bei einem verwiesenen Pool hinge daran der Login des fremden
// Verzeichnisses — den löscht die App nicht.
if let Some(dir) = read_pool(&paths, &pool)?.dir {
return Err(format!(
"Neuanmeldung geht bei einem verwiesenen Pool nicht ({dir}) — dort meldet claude selbst an"
));
}
let running = running_projects_using_pool(&paths, &pool)?; let running = running_projects_using_pool(&paths, &pool)?;
if !running.is_empty() { if !running.is_empty() {
@@ -234,7 +243,7 @@ pub(crate) fn oauth_login(pool: String) -> Result<(), String> {
)); ));
} }
let service = keychain_service(&paths, &pool); let service = keychain_service(&paths, &pool)?;
if crate::platform::oauth_keychain_exists(&service)? { if crate::platform::oauth_keychain_exists(&service)? {
crate::platform::oauth_keychain_delete(&service)?; crate::platform::oauth_keychain_delete(&service)?;
} }
@@ -338,11 +347,6 @@ pub(crate) fn set_terminal_font_size(size: u32) -> Result<(), String> {
settings::set_terminal_font_size_in(&Paths::real(), size) settings::set_terminal_font_size_in(&Paths::real(), size)
} }
#[tauri::command]
pub(crate) fn spellcheck_lang() -> String {
settings::spellcheck_lang(&Paths::real())
}
// ---------- Verbrauch ---------- // ---------- Verbrauch ----------
#[tauri::command] #[tauri::command]
@@ -350,59 +354,139 @@ pub(crate) fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
usage_stats_in(&Paths::real(), days) usage_stats_in(&Paths::real(), days)
} }
// ---------- Panel-Archiv ---------- /// Modul-Registry für den Settings-Dialog (Checkbox-Gruppe „Module").
/// Konfiguriertes Archiv-Home des Projekts (für den Panel-Button: entscheidet,
/// ob der Ordner-Dialog nötig ist).
#[tauri::command] #[tauri::command]
pub(crate) fn panel_archive_dir_cmd(project: String) -> Option<String> { pub(crate) fn module_registry(project: String) -> Result<Vec<crate::domain::modules::ModuleInfo>, String> {
project_archive_home(&project).map(|p| p.display().to_string()) crate::domain::modules::module_infos_in(&Paths::real(), &project)
} }
/// Setzt das Archiv-Home eines Projekts (Einstellungsdialog) — inklusive /// Schaltet ein Modul im Projekt an/ab (Settings-Dialog, schreibt sofort).
/// Permissions-Eintrag in der Projekt-settings.json.
#[tauri::command] #[tauri::command]
pub(crate) fn set_archive_home_cmd(project: String, dir: String) -> Result<(), String> { pub(crate) fn set_module(project: String, module: String, enabled: bool) -> Result<(), String> {
crate::domain::archive::set_project_archive_home(&project, &dir) crate::domain::modules::set_module_in(&Paths::real(), &project, &module, enabled)
} }
/// Wählt das Archiv ab (Einstellungsdialog): Config-Eintrag und Permissions /// Aktive Module fürs Frontend (Registry-Enablement der Projekt-Config).
/// weg, der Ordner bleibt liegen.
#[tauri::command] #[tauri::command]
pub(crate) fn clear_archive_home_cmd(project: String) -> Result<(), String> { pub(crate) fn enabled_modules(project: String) -> Result<Vec<String>, String> {
crate::domain::archive::clear_project_archive_home(&project) Ok(
crate::domain::modules::active_in(&Paths::real(), &project)?
.iter()
.map(|m| m.id.to_string())
.collect(),
)
}
// ---------- Trilium ----------
/// Wizard-Status: Runtime da, Instanz erreichbar, Token gültig. Reine
/// Checks, keine Schreiber.
#[derive(serde::Serialize)]
pub(crate) struct TriliumStatus {
version: &'static str,
port: u16,
data_dir: String,
runtime_ready: bool,
instance_running: bool,
token_ok: bool,
}
fn trilium_status_for(project: &str) -> TriliumStatus {
let paths = Paths::real();
let instance_running = crate::domain::trilium::instance_running(project);
TriliumStatus {
version: crate::domain::trilium::VERSION,
port: crate::domain::trilium::port_for(project),
data_dir: crate::domain::paths::contract_home(
&paths,
&crate::domain::trilium::data_dir(&paths, project),
),
runtime_ready: crate::domain::trilium::runtime_ready(&paths),
instance_running,
token_ok: instance_running && crate::domain::trilium::token_ok(project),
}
} }
/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt
/// das Archiv-Home zugleich; ohne `dir` muss es konfiguriert sein. Ordner,
/// Beschreibung und Schlagwörter kommen aus dem Archiv-Formular im Panel.
#[tauri::command] #[tauri::command]
pub(crate) fn panel_archive_cmd( pub(crate) async fn trilium_status(project: String) -> TriliumStatus {
trilium_status_for(&project)
}
/// Richtet die Projekt-Instanz ein: Runtime laden (gepinnt, Checksum),
/// Instanz starten. Schreibt KEINE Config — das Modul-Flag setzt erst
/// trilium_connect nach erfolgreichem Token-Login.
#[tauri::command]
pub(crate) async fn trilium_setup(project: String) -> Result<TriliumStatus, String> {
crate::domain::trilium::ensure_instance(&Paths::real(), &project)?;
Ok(trilium_status_for(&project))
}
/// Richtet die Instanz mit dem Passwort aus dem Wizard vollständig ein:
/// Erstlauf legt das Dokument an und setzt das Passwort, dann ETAPI-Token
/// holen, im Keyring ablegen und ERST DANN das Modul in der Projekt-Config
/// freischalten. Das Passwort wird nicht gespeichert.
#[tauri::command]
pub(crate) async fn trilium_connect(
project: String, project: String,
dir: Option<String>, password: String,
folder: Option<String>, ) -> Result<TriliumStatus, String> {
description: Option<String>, let paths = Paths::real();
tags: Option<Vec<String>>, crate::domain::trilium::ensure_instance(&paths, &project)?;
) -> Result<String, String> { if !crate::domain::trilium::initialized(&project)? {
let meta = ArchiveMeta { crate::domain::trilium::setup_new_document(&project)?;
folder: folder.filter(|f| !f.trim().is_empty()), crate::domain::trilium::set_password(&project, &password)?;
description: description.filter(|d| !d.trim().is_empty()), }
tags: tags.unwrap_or_default(), let token = crate::domain::trilium::login(&project, &password)?;
}; crate::domain::trilium::store_token(&project, &token)?;
archive_panel_content(&project, dir.as_deref(), &meta).map(|p| p.display().to_string()) if !crate::domain::trilium::token_ok(&project) {
crate::domain::trilium::delete_token(&project);
return Err("Token angenommen, aber ETAPI-Zugriff scheitert".into());
}
crate::domain::modules::set_module_in(&paths, &project, "trilium", true)?;
Ok(trilium_status_for(&project))
} }
/// Dokumentliste des Archiv-Index (frisch gescannt) fürs Panel. /// Öffnet das Trilium-Fenster des Projekts (startet die Instanz bei Bedarf);
/// `note_id` springt zur Notiz. Async wie open_panel_window (Fenster-Erzeugung
/// aus synchronen Commands klemmt auf dem GTK-Mainloop).
#[tauri::command] #[tauri::command]
pub(crate) fn archive_docs_cmd( pub(crate) async fn trilium_open(
app: tauri::AppHandle,
project: String, project: String,
) -> Result<Vec<crate::domain::archive_index::Doc>, String> { note_id: Option<String>,
let home = require_archive_home(&project)?; ) -> Result<(), String> {
crate::domain::archive_index::scan_archive(&home) use tauri::Manager;
crate::domain::trilium::ensure_instance(&Paths::real(), &project)?;
let mut url = crate::domain::trilium::base_url(&project);
if let Some(id) = &note_id {
url.push_str(&format!("/#root/{id}"));
}
let label = format!("trilium-{project}");
if let Some(win) = app.get_webview_window(&label) {
if note_id.is_some() {
win
.eval(&format!("window.location.href = {}", serde_json::json!(url)))
.map_err(|e| e.to_string())?;
}
win.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
let parsed: tauri::Url = url.parse().map_err(|e| format!("{url}: {e}"))?;
tauri::WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::External(parsed))
.title(format!("Trilium — {}", crate::domain::project::display_name_in(&Paths::real(), &project)?))
.inner_size(1100.0, 780.0)
.build()
.map_err(|e| e.to_string())?;
Ok(())
} }
/// Zeigt einen Pfad im Dateimanager (nach dem Archivieren „im Finder zeigen"). /// Wählt das Modul ab und stoppt die Projekt-Instanz. Daten und Token
/// bleiben — erneutes Aktivieren verbindet sich wieder.
#[tauri::command] #[tauri::command]
pub(crate) fn reveal_path_cmd(path: String) { pub(crate) async fn trilium_disable(project: String) -> Result<(), String> {
crate::platform::reveal_path(std::path::Path::new(&path)); crate::domain::modules::set_module_in(&Paths::real(), &project, "trilium", false)?;
crate::domain::trilium::stop_instance(&project);
Ok(())
} }
-487
View File
@@ -1,487 +0,0 @@
//! Archiv: Panel-Entwürfe als Markdown-Dateien mit Frontmatter im
//! konfigurierten Archiv-Ordner des Projekts persistieren.
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::domain::paths::{contract_home, expand_home, panel_file, Paths};
use crate::domain::project::{
read_project_config_in, settings_path, write_project_config_in,
};
use crate::domain::registry::project_dir;
/// Konfiguriertes Archiv-Home des Projekts (config.json: archiveHome),
/// Home-expandiert; None, wenn nicht gesetzt.
pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
let paths = Paths::real();
let dir = read_project_config_in(&paths, project).ok()?.archive_home?;
Some(expand_home(&paths, &dir))
}
/// Archiv-Home als Result — die gemeinsame Vorbedingung aller Archiv-Tools.
/// Legt den konfigurierten Ordner an, falls er (noch) nicht existiert.
pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
let home = project_archive_home(project).ok_or("kein Archiv-Ordner gesetzt")?;
fs::create_dir_all(&home).map_err(|e| format!("{}: {e}", home.display()))?;
Ok(home)
}
/// Setzt das Archiv-Home: config.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_home(project: &str, dir: &str) -> Result<(), String> {
let paths = Paths::real();
let expanded = expand_home(&paths, dir);
if !expanded.is_absolute() {
return Err(format!("Archiv-Ordner muss ein absoluter Pfad sein: {}", expanded.display()));
}
// Zu weit gefasst? Ein Vorfahre des Home (inkl. Home selbst und Root) würde
// claude über additionalDirectories weiten Zugriff geben — das lehnen wir ab.
if paths.home.starts_with(&expanded) {
return Err(format!(
"Archiv-Ordner zu weit gefasst: {}. Bitte einen spezifischen Unterordner wählen.",
expanded.display()
));
}
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
let contracted = contract_home(&paths, &expanded);
let mut cfg = read_project_config_in(&paths, project)?;
cfg.archive_home = Some(contracted.clone());
write_project_config_in(&paths, project, &cfg)?;
add_archive_permission(&paths, project, &contracted)
}
/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein.
/// Auch beim Projekt-Import im Einsatz (mitgebrachtes archiveHome).
pub(crate) fn add_archive_permission(
paths: &Paths,
project: &str,
dir: &str,
) -> Result<(), String> {
let sp = settings_path(&project_dir(paths, project)?);
let mut v: serde_json::Value = if sp.is_file() {
serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?)
.map_err(|e| format!("{}: {e}", sp.display()))?
} else {
serde_json::json!({})
};
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
let perms = root
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or("permissions ist kein Objekt")?;
let dirs = perms
.entry("additionalDirectories")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("additionalDirectories ist kein Array")?;
if !dirs.iter().any(|d| d.as_str() == Some(dir)) {
dirs.push(serde_json::json!(dir));
}
let edit = format!("Edit({dir}/**)");
let allow = perms
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("allow ist kein Array")?;
if !allow.iter().any(|p| p.as_str() == Some(&edit)) {
allow.push(serde_json::json!(edit));
}
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// Wählt das Archiv ab: archiveHome aus der config.json entfernen und die
/// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus
/// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen.
pub(crate) fn clear_project_archive_home(project: &str) -> Result<(), String> {
let paths = Paths::real();
let mut cfg = read_project_config_in(&paths, project)?;
let Some(dir) = cfg.archive_home.take() else {
return Ok(());
};
write_project_config_in(&paths, project, &cfg)?;
remove_archive_permission(&paths, project, &dir)
}
/// Gegenstück zu add_archive_permission; auch der Lösch-Dialog (Stufe „nur
/// Integration") nimmt darüber die Archiv-Rechte zurück.
pub(crate) fn remove_archive_permission(
paths: &Paths,
project: &str,
dir: &str,
) -> Result<(), String> {
let sp = settings_path(&project_dir(paths, project)?);
if !sp.is_file() {
return Ok(());
}
let mut v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?)
.map_err(|e| format!("{}: {e}", sp.display()))?;
let Some(perms) = v.get_mut("permissions").and_then(|p| p.as_object_mut()) else {
return Ok(());
};
if let Some(dirs) = perms.get_mut("additionalDirectories").and_then(|d| d.as_array_mut()) {
dirs.retain(|d| d.as_str() != Some(dir));
}
let edit = format!("Edit({dir}/**)");
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
allow.retain(|p| p.as_str() != Some(&edit));
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// 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-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> {
// Erst prüfen, dann konfigurieren: `set_project_archive_home` legt Ordner an
// und trägt eine Berechtigung in die settings.json des Projekts ein. Käme das
// vor der Leer-Prüfung, hinterließe ein Archivieren mit leerem Panel die
// Meldung „nicht archiviert“ — und ein dauerhaft umgestelltes Archiv-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());
}
let folder = meta.folder.as_deref().map(check_folder).transpose()?;
if let Some(d) = dir_override {
set_project_archive_home(project, d)?;
}
let home = require_archive_home(project)?;
let dir = match folder {
Some(f) => home.join(f),
None => home,
};
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs();
let (stamp, iso) = utc_stamp(secs);
let title = first_line(&text);
let (path, mut file) = create_unique(&dir, &stamp, &slugify(&title))?;
// Frontmatter trägt den Anzeigenamen, nicht die Projekt-ID.
let name = crate::domain::project::display_name_in(&Paths::real(), project)?;
let doc = format!("{}{}\n", frontmatter(&title, &name, &iso, meta), text.trim_end());
std::io::Write::write_all(&mut file, doc.as_bytes())
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(path)
}
/// Archiv-Dokument kollisionsfrei anlegen.
///
/// Der Zeitstempel hat Minutenauflösung; zweimal Archivieren innerhalb einer
/// Minute mit derselben Titelzeile ergäbe denselben Namen — bei Kollision
/// wird `-2`, `-3`, … angehängt. Die Garantie kommt vom Dateisystem
/// (`create_new`), nicht von einem `exists()`-Vorabblick: Beim Archiv-Sync
/// über zwei Maschinen wäre der Vorabblick ein TOCTOU-Fenster, und stiller
/// Datenverlust träfe ausgerechnet die dauerhafte Ablage.
fn create_unique(
dir: &std::path::Path,
stamp: &str,
slug: &str,
) -> Result<(PathBuf, fs::File), String> {
for n in 1.. {
let name = if n == 1 {
format!("{stamp}-{slug}.md")
} else {
format!("{stamp}-{slug}-{n}.md")
};
let path = dir.join(name);
match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => return Ok((path, file)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("{}: {e}", path.display())),
}
}
unreachable!()
}
/// 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;
for line in text.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
let h = t.trim_start_matches('#').trim();
if t.starts_with('#') && !h.is_empty() {
return h.to_string();
}
fallback.get_or_insert(t);
}
fallback.unwrap_or("entwurf").to_string()
}
/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).
pub(crate) fn slugify(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
// Umlaute/ß transliterieren statt verschlucken („für's" → fuer-s).
match c {
'ä' | 'Ä' => out.push_str("ae"),
'ö' | 'Ö' => out.push_str("oe"),
'ü' | 'Ü' => out.push_str("ue"),
'ß' => out.push_str("ss"),
_ if c.is_ascii_alphanumeric() => out.push(c.to_ascii_lowercase()),
_ if !out.ends_with('-') => out.push('-'),
_ => {}
}
}
let out: String = out.trim_matches('-').chars().take(60).collect();
if out.is_empty() {
"entwurf".into()
} else {
out
}
}
/// UTC aus Epoch-Sekunden: (Dateistempel `YYYY-MM-DD_HHMM`, ISO `…Z`).
fn utc_stamp(secs: u64) -> (String, String) {
let (h, mi, s) = (secs / 3600 % 24, secs / 60 % 60, secs % 60);
let (y, m, d) = civil_from_days((secs / 86400) as i64);
(
format!("{y:04}-{m:02}-{d:02}_{h:02}{mi:02}"),
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"),
)
}
/// Kalenderdatum aus Tagen seit 1970-01-01 (Howard-Hinnant-Algorithmus).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utc_stamp_bekannte_zeit() {
// 2026-07-11 (20645 Tage seit Epoch) um 11:14:15 UTC.
let secs = 20_645u64 * 86400 + 11 * 3600 + 14 * 60 + 15;
let (stamp, iso) = utc_stamp(secs);
assert_eq!(stamp, "2026-07-11_1114");
assert_eq!(iso, "2026-07-11T11:14:15Z");
}
/// Zweimal Archivieren in derselben Minute mit gleichem Titel darf die erste
/// Datei nicht überschreiben — der Stempel hat nur Minutenauflösung, und
/// die Datei entsteht bereits beim Anlegen (create_new), nicht erst beim
/// Schreiben.
#[test]
fn gleicher_stempel_und_titel_kollidiert_nicht() {
let dir = crate::domain::testutil::tmp_paths().home.join("archiv");
fs::create_dir_all(&dir).unwrap();
let (erste, mut f1) = create_unique(&dir, "2026-07-19_2118", "notiz").unwrap();
assert_eq!(erste.file_name().unwrap(), "2026-07-19_2118-notiz.md");
std::io::Write::write_all(&mut f1, b"alt").unwrap();
let (zweite, _f2) = create_unique(&dir, "2026-07-19_2118", "notiz").unwrap();
assert_eq!(zweite.file_name().unwrap(), "2026-07-19_2118-notiz-2.md");
assert_eq!(fs::read_to_string(&erste).unwrap(), "alt");
let (dritte, _f3) = create_unique(&dir, "2026-07-19_2118", "notiz").unwrap();
assert_eq!(dritte.file_name().unwrap(), "2026-07-19_2118-notiz-3.md");
}
#[test]
fn civil_from_days_referenz() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(20_645), (2026, 7, 11));
}
#[test]
fn slugify_grenzen() {
assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen");
assert_eq!(slugify(" "), "entwurf");
assert_eq!(slugify("###"), "entwurf");
assert_eq!(slugify("Ein Test für's Archiv"), "ein-test-fuer-s-archiv");
assert_eq!(slugify("Größe ÄÖÜ"), "groesse-aeoeue");
}
#[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");
assert_eq!(first_line("Kein Heading\nzweite"), "Kein Heading");
assert_eq!(first_line(" \n"), "entwurf");
}
}
-400
View File
@@ -1,400 +0,0 @@
//! 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>,
/// Die neuesten Dokumente über alle Ordner; leer, wenn es nur einen
/// Ordner gibt (dann wäre die Sektion eine Dublette der Liste).
pub(crate) recent: Vec<WikiDocEntry>,
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>,
/// Zahl der Dokumente, die per Wikilink hierher zeigen.
pub(crate) backlinks: usize,
}
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);
}
let recent = if folders.len() > 1 {
// Neueste zuerst über alle Ordner — der Zeitstempel-Stem sortiert
// chronologisch, verglichen wird der Dateiname.
let mut all = selected.clone();
all.sort_by(|a, b| {
Path::new(&b.relpath).file_name().cmp(&Path::new(&a.relpath).file_name())
});
all.into_iter().take(5).map(doc_entry).collect()
} else {
Vec::new()
};
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(),
recent,
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,
backlinks: doc.backlinks.len(),
}
}
/// 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");
// Zwei Ordner → Zuletzt-Sektion, neuestes Dokument zuerst, mit
// Backlink-Zähler aus dem Scan.
let recent: Vec<&str> = page.recent.iter().map(|d| d.name.as_str()).collect();
assert_eq!(recent, vec!["notiz-deploy", "adr-logging"]);
assert_eq!(page.recent[0].backlinks, 1);
assert_eq!(page.recent[1].backlinks, 0);
}
#[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());
}
}
-227
View File
@@ -1,227 +0,0 @@
//! 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 q = sanitize_query(query);
let t = tag.and_then(|t| quote_phrase(t, false)).map(|t| format!("tags:{t}"));
let expr = match (q.is_empty(), t) {
(false, Some(t)) => format!("({q}) AND {t}"),
(false, None) => q,
(true, Some(t)) => t,
// Von vornherein leere Anfrage ist ein Bedienfehler. Eine Eingabe, die nur
// aus Satzzeichen bestand (`!?`, ein einzelnes `"`, das angetippte `#`),
// findet dagegen schlicht nichts — und spart sich den Index-Aufbau.
(true, None) if query.trim().is_empty() && tag.is_none() => {
return Err("leere Suchanfrage".into())
}
(true, None) => return Ok(Vec::new()),
};
let conn = build_index(home)?;
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())
}
/// Übersetzt die Nutzereingabe in einen FTS5-Ausdruck, der nicht scheitern kann.
///
/// Roh durchgereicht ist fast jede natürliche Eingabe ein Syntaxfehler: `ai-control`
/// liest FTS5 als Spaltenfilter (`no such column: control`), `C++` und eine offene
/// Klammer brechen den Parser. Die Live-Suche schickt zudem jeden Zwischenstand beim
/// Tippen ab, also auch das halbe `"Phrase`. Darum wird jedes Wort als Phrase
/// gequotet — die Tokenizer-Regeln bleiben dieselben, nur die Operatorzeichen
/// verlieren ihre Sonderbedeutung. Erhalten bleiben die zwei Formen, die Nutzer
/// bewusst tippen: "Phrasen in Anführungszeichen" und Präfix*.
fn sanitize_query(query: &str) -> String {
let mut out: Vec<String> = Vec::new();
// An `"` aufteilen: ungerade Segmente standen in Anführungszeichen und
// bleiben als Ganzes eine Phrase, gerade zerfallen in Wörter. Ein fehlendes
// schließendes Anführungszeichen (Tippzwischenstand) fällt damit von selbst
// richtig heraus — der Rest der Eingabe ist das letzte ungerade Segment.
for (i, teil) in query.split('"').enumerate() {
if i % 2 == 1 {
out.extend(quote_phrase(teil, false));
} else {
out.extend(
teil
.split_whitespace()
.filter_map(|w| quote_phrase(w.trim_end_matches('*'), w.ends_with('*'))),
);
}
}
out.join(" ")
}
/// `term` als gequotete FTS5-Phrase, sofern überhaupt etwas Indexierbares darin
/// steht. Reine Satzzeichen ergäben die leere Phrase `""` — für FTS5 ein
/// Syntaxfehler. Gilt auch für den Tag-Filter: Das angetippte `#` liefert über
/// `panel-wiring.ts` den leeren Tag.
fn quote_phrase(term: &str, prefix: bool) -> Option<String> {
if !term.chars().any(char::is_alphanumeric) {
return None;
}
let quoted = term.replace('"', "");
Some(if prefix { format!("\"{quoted}\"*") } else { format!("\"{quoted}\"") })
}
/// 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());
}
/// Eingaben, die roh durchgereicht einen FTS5-Syntaxfehler warfen. Die
/// Bindestrich-Fälle sind die wichtigsten: Archiv-Dokumente heißen selbst so.
/// Die angefangenen Phrasen stehen für die Tippzwischenstände, die die
/// Live-Suche abschickt.
#[test]
fn sonderzeichen_werfen_keinen_syntaxfehler() {
let home = archiv();
let eingaben = [
"ai-control",
"adr-log",
"TODO: fix",
"C++",
"wiki (",
"\"",
"\"Strukturiertes",
"\"Strukturiertes Logging",
"!?",
];
for q in eingaben {
assert!(search(&home, q, None, 10).is_ok(), "Suche scheiterte an „{q}“");
}
}
/// Der Tag-Filter lief früher an der Quoting-Regel vorbei. `#` allein ist der
/// erste Tastendruck jeder Tag-Suche und kommt als leerer Tag an.
#[test]
fn leerer_tag_wirft_keinen_syntaxfehler() {
let home = archiv();
// Das angetippte `#`: kein Fehler-Toast, sondern schlicht kein Treffer.
assert_eq!(search(&home, "", Some(""), 10).unwrap().len(), 0);
assert_eq!(search(&home, "", Some("!?"), 10).unwrap().len(), 0);
// Mit Volltext daneben zählt nur dieser, der leere Tag engt nichts ein.
assert_eq!(search(&home, "tracing", Some(""), 10).unwrap().len(), 1);
// Fehler bleibt allein die komplett leere Anfrage ohne jeden Tag.
assert!(search(&home, "", None, 10).is_err());
}
#[test]
fn bindestrich_wort_findet_dokument() {
let home = archiv();
let hits = search(&home, "adr-logging", None, 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "ADR Logging");
}
}
+29 -5
View File
@@ -23,15 +23,28 @@ pub(crate) trait ApikeyStore {
fn delete(&self, pool: &str) -> Result<(), String>; fn delete(&self, pool: &str) -> Result<(), String>;
} }
/// Keychain-Service-Name des OAuth-Pools: claude legt pro CLAUDE_CONFIG_DIR /// Keychain-Service-Name eines CLAUDE_CONFIG_DIR: claude legt pro Verzeichnis
/// einen suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256 /// einen suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256
/// über den Pool-Pfad. /// über den Verzeichnispfad. Claudes Default-Verzeichnis läuft ohne gesetzte
pub(crate) fn keychain_service(paths: &Paths, pool: &str) -> String { /// Variable und trägt deshalb den unsuffixierten Eintrag — ein Pool, der
let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes()); /// darauf verweist, erbt damit einen bestehenden Login.
pub(crate) fn keychain_service_for(paths: &Paths, dir: &std::path::Path) -> String {
if dir == paths.default_claude_dir() {
return "Claude Code-credentials".into();
}
let hash = Sha256::digest(dir.to_string_lossy().as_bytes());
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect(); let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
format!("Claude Code-credentials-{}", &hex[..8]) format!("Claude Code-credentials-{}", &hex[..8])
} }
/// Keychain-Service-Name eines Pools über sein aufgelöstes Config-Verzeichnis.
pub(crate) fn keychain_service(paths: &Paths, pool: &str) -> Result<String, String> {
Ok(keychain_service_for(
paths,
&crate::domain::pool::pool_config_dir(paths, pool)?,
))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -43,11 +56,22 @@ mod tests {
fn keychain_service_suffix() { fn keychain_service_suffix() {
let p = Paths { home: PathBuf::from("/Users/marcus.hinz") }; let p = Paths { home: PathBuf::from("/Users/marcus.hinz") };
assert_eq!( assert_eq!(
keychain_service(&p, "privateDefault"), keychain_service_for(&p, &p.pool_dir("privateDefault")),
"Claude Code-credentials-096c4ef9" "Claude Code-credentials-096c4ef9"
); );
} }
/// Claudes Default-Verzeichnis läuft ohne CLAUDE_CONFIG_DIR und trägt den
/// unsuffixierten Eintrag (am echten System 2026-07-22 gegengeprüft).
#[test]
fn keychain_service_default_dir_ohne_suffix() {
let p = Paths { home: PathBuf::from("/Users/marcus.hinz") };
assert_eq!(
keychain_service_for(&p, &p.default_claude_dir()),
"Claude Code-credentials"
);
}
#[test] #[test]
fn apikey_helper_kette_referenz() { fn apikey_helper_kette_referenz() {
let dir = PathBuf::from("/pools/abc"); let dir = PathBuf::from("/pools/abc");
+56 -4
View File
@@ -2,16 +2,14 @@
//! (injizierbares Home) und den `ApikeyStore`-Trait testbar; OS-Aufrufe //! (injizierbares Home) und den `ApikeyStore`-Trait testbar; OS-Aufrufe
//! laufen ausschließlich über crate::platform. //! 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 credentials;
pub(crate) mod modules;
pub(crate) mod paths; pub(crate) mod paths;
pub(crate) mod trilium;
pub(crate) mod pool; pub(crate) mod pool;
pub(crate) mod project; pub(crate) mod project;
pub(crate) mod registry; pub(crate) mod registry;
pub(crate) mod settings; pub(crate) mod settings;
pub(crate) mod todo;
pub(crate) mod usage; pub(crate) mod usage;
pub(crate) mod watcher; pub(crate) mod watcher;
@@ -38,6 +36,60 @@ pub(crate) fn write_atomic(path: &std::path::Path, content: &str) -> Result<(),
}) })
} }
/// Read-modify-write auf dem `permissions`-Objekt einer Claude-settings.json
/// (Projekt wie Pool). `create` behandelt eine fehlende Datei als leeres
/// Objekt — für Einträge, die die Datei erst anlegen; ohne `create` scheitert
/// das Lesen laut. Geschrieben wird nur, wenn `f` den Inhalt tatsächlich
/// verändert hat — die Datei gehört claude, unnötige Writes unterbleiben.
pub(crate) fn update_settings_permissions(
sp: &std::path::Path,
create: bool,
f: impl FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> Result<(), String>,
) -> Result<(), String> {
let mut v: serde_json::Value = if !sp.is_file() && create {
serde_json::json!({})
} else {
let raw = std::fs::read_to_string(sp).map_err(|e| format!("{}: {e}", sp.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
};
let before = v.clone();
{
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
let perms = root
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or("permissions ist kein Objekt")?;
f(perms)?;
}
// Ein hier erst angelegtes, leer gebliebenes permissions-Objekt wieder
// entfernen — sonst zählte ein No-op als Änderung.
if before.get("permissions").is_none()
&& v["permissions"].as_object().is_some_and(|o| o.is_empty())
{
v.as_object_mut().unwrap().remove("permissions");
}
if v == before {
return Ok(());
}
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
write_atomic(sp, &(raw + "\n"))
}
/// Array-Feld im permissions-Objekt, bei Bedarf angelegt.
pub(crate) fn perm_array<'a>(
perms: &'a mut serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Result<&'a mut Vec<serde_json::Value>, String> {
perms
.entry(key)
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| format!("{key} ist kein Array"))
}
/// Namensprüfung für Projekte und Pool-Anzeigenamen. /// Namensprüfung für Projekte und Pool-Anzeigenamen.
/// ///
/// Der Name wird als Pfadsegment verwendet (Pool-Verzeichnis, Panel-Dateien) /// Der Name wird als Pfadsegment verwendet (Pool-Verzeichnis, Panel-Dateien)
+274
View File
@@ -0,0 +1,274 @@
//! Modul-Registry: benennt die Funktionsblöcke der App (Entwurf, Befehle,
//! Archiv, …) mit ihren Beiträgen — MCP-Tools und Puffer-Kanäle. Die Tabelle
//! ist die eine Quelle für „was ist in diesem Projekt an?": Das Frontend baut
//! seine Tabs daraus (`enabled_modules`), der MCP-Server seine Tool-Liste
//! (tools/list) und den Guard vor dem Dispatch (tools/call).
//!
//! Die Abwahl eines Moduls ist Konfiguration, keine Sicherheitsgrenze —
//! Commands bleiben registriert, die Capability-Manifeste unverändert;
//! dieselbe Linie wie bei den Pools (Konfiguration trennen, nicht Zugriff).
use crate::domain::paths::Paths;
/// Ein Puffer-Kanal eines Moduls: MCP-Server oder Command schreibt die Datei,
/// der Watcher im Terminal-Prozess meldet neuen Inhalt als Event an die
/// Panel-Fenster.
pub(crate) struct BufferDesc {
/// Schlüssel für `buffer_read` (Erstbefüllung der Ansicht).
pub(crate) id: &'static str,
/// Env-Variable, unter der die PTY den Dateipfad an claudes MCP-Kinder gibt.
pub(crate) env: &'static str,
/// Event an die Panel-Fenster bei Dateiänderung.
pub(crate) event: &'static str,
/// Pufferdatei des Projekts.
pub(crate) file: fn(&str) -> std::path::PathBuf,
/// Dateiname-Suffix (`<projekt>.<suffix>`) — muss zu `file` passen
/// (Test buffer_datei_passt_zum_suffix); Grundlage der Löschvorschau.
pub(crate) suffix: &'static str,
/// Persistente Puffer überleben Sessions — der Session-Start legt sie nur
/// an, statt sie zu leeren.
pub(crate) persistent: bool,
}
pub(crate) struct ModuleDesc {
pub(crate) id: &'static str,
/// Kern-Module sind nicht abschaltbar; ein Config-Eintrag wird ignoriert.
pub(crate) core: bool,
/// Aktiv ohne Eintrag in der Projekt-Config.
pub(crate) default_enabled: bool,
pub(crate) mcp_tools: &'static [&'static str],
pub(crate) buffers: &'static [BufferDesc],
}
pub(crate) const MODULES: &[ModuleDesc] = &[
ModuleDesc {
id: "commands",
core: false,
default_enabled: true,
mcp_tools: &["write_commands", "show_commands"],
buffers: &[BufferDesc {
id: "commands",
env: "AI_CONTROL_COMMANDS",
event: "commands-update",
file: crate::domain::paths::commands_file,
suffix: "commands.jsonl",
persistent: false,
}],
},
ModuleDesc {
id: "todo",
core: false,
// Opt-in: der Tab erscheint erst, wenn das Modul im Projekt gewählt ist.
default_enabled: false,
mcp_tools: &["write_todos", "show_todos"],
buffers: &[BufferDesc {
id: "todos",
env: "AI_CONTROL_TODOS",
event: "todos-update",
file: crate::domain::paths::todos_file,
suffix: "todos.jsonl",
persistent: true,
}],
},
ModuleDesc {
id: "trilium",
core: false,
// Opt-in: das Anwählen öffnet im Settings-Dialog den Einricht-Wizard;
// das Modul-Flag schreibt erst der erfolgreiche Setup (Erst-nach-Erfolg).
// Das Modul IST das Archiv: Archivieren und Volltextsuche laufen über
// die ETAPI der projekteigenen Trilium-Instanz.
default_enabled: false,
mcp_tools: &["write_panel", "search_archive"],
buffers: &[
BufferDesc {
id: "search",
env: "AI_CONTROL_SEARCH",
event: "search-update",
file: crate::domain::paths::search_file,
suffix: "search.json",
persistent: false,
},
// Signal-Kanal „Notiz öffnen": write_panel legt die Notiz per ETAPI an
// und schreibt hier die noteId; die Fenster reagieren mit trilium_open.
BufferDesc {
id: "noteopen",
env: "AI_CONTROL_NOTEOPEN",
event: "noteopen-update",
file: crate::domain::paths::noteopen_file,
suffix: "noteopen.json",
persistent: false,
},
],
},
];
/// Aktive Module des Projekts: MODULES, gefiltert durch die `modules`-
/// Abweichungen der Projekt-Config. Kern-Module sind immer dabei.
pub(crate) fn active_in(
paths: &Paths,
project: &str,
) -> Result<Vec<&'static ModuleDesc>, String> {
let cfg = crate::domain::project::read_project_config_in(paths, project)?;
Ok(
MODULES
.iter()
.filter(|m| {
m.core || cfg.modules.get(m.id).copied().unwrap_or(m.default_enabled)
})
.collect(),
)
}
/// Modul, das dieses MCP-Tool beiträgt.
pub(crate) fn by_tool(tool: &str) -> Option<&'static ModuleDesc> {
MODULES.iter().find(|m| m.mcp_tools.contains(&tool))
}
/// Registry-Zeile für den Settings-Dialog: `enabled` ist der effektive
/// Config-Schalter.
#[derive(serde::Serialize)]
pub(crate) struct ModuleInfo {
pub(crate) id: &'static str,
pub(crate) core: bool,
pub(crate) enabled: bool,
}
pub(crate) fn module_infos_in(
paths: &Paths,
project: &str,
) -> Result<Vec<ModuleInfo>, String> {
let cfg = crate::domain::project::read_project_config_in(paths, project)?;
Ok(
MODULES
.iter()
.map(|m| ModuleInfo {
id: m.id,
core: m.core,
enabled: m.core || cfg.modules.get(m.id).copied().unwrap_or(m.default_enabled),
})
.collect(),
)
}
/// Schreibt die Modul-Abweichung in die Projekt-Config; der Default-Wert
/// löscht den Eintrag (nur Abweichungen werden gespeichert).
pub(crate) fn set_module_in(
paths: &Paths,
project: &str,
module: &str,
enabled: bool,
) -> Result<(), String> {
let m = MODULES
.iter()
.find(|m| m.id == module)
.ok_or_else(|| format!("unbekanntes Modul: {module}"))?;
if m.core {
return Err(format!("Modul {module} ist nicht abschaltbar"));
}
let mut cfg = crate::domain::project::read_project_config_in(paths, project)?;
if enabled == m.default_enabled {
cfg.modules.remove(module);
} else {
cfg.modules.insert(module.to_string(), enabled);
}
crate::domain::project::write_project_config_in(paths, project, &cfg)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::{create_project, tmp_paths};
fn ids(mods: &[&'static ModuleDesc]) -> Vec<&'static str> {
mods.iter().map(|m| m.id).collect()
}
#[test]
fn defaults_alle_aktiv() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
assert_eq!(
ids(&active_in(&p, "proj").unwrap()),
vec!["commands"]
);
}
#[test]
fn config_schaltet_ab_kern_bleibt() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let cfg = p
.projects_dir()
.join("proj")
.join(".ai-control")
.join("config.json");
std::fs::write(
&cfg,
r#"{"id": "proj", "name": "proj", "modules": {"commands": false}}"#,
)
.unwrap();
assert!(active_in(&p, "proj").unwrap().is_empty());
}
#[test]
fn unbekanntes_projekt_scheitert() {
let p = tmp_paths();
assert!(active_in(&p, "fehlt").is_err());
}
#[test]
fn todo_ist_opt_in() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
set_module_in(&p, "proj", "todo", true).unwrap();
assert_eq!(
ids(&active_in(&p, "proj").unwrap()),
vec!["commands", "todo"]
);
}
#[test]
fn buffer_datei_passt_zum_suffix() {
for b in MODULES.iter().flat_map(|m| m.buffers) {
let name = (b.file)("proj");
let name = name.file_name().unwrap().to_str().unwrap();
assert_eq!(name, format!("proj.{}", b.suffix));
}
}
#[test]
fn set_module_roundtrip() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
set_module_in(&p, "proj", "commands", false).unwrap();
assert!(active_in(&p, "proj").unwrap().is_empty());
assert!(!module_infos_in(&p, "proj").unwrap()[0].enabled);
// Zurück auf den Default löscht den Eintrag statt `true` zu speichern.
set_module_in(&p, "proj", "commands", true).unwrap();
let cfg = crate::domain::project::read_project_config_in(&p, "proj").unwrap();
assert!(cfg.modules.is_empty());
// Unbekannte ID scheitert laut.
assert!(set_module_in(&p, "proj", "gibtsnicht", true).is_err());
}
#[test]
fn tool_zuordnung() {
assert_eq!(by_tool("write_panel").unwrap().id, "trilium");
assert_eq!(by_tool("search_archive").unwrap().id, "trilium");
assert!(by_tool("unbekannt").is_none());
}
#[test]
fn buffer_und_tool_ids_eindeutig() {
let mut tools: Vec<&str> = MODULES.iter().flat_map(|m| m.mcp_tools).copied().collect();
let mut buffers: Vec<&str> =
MODULES.iter().flat_map(|m| m.buffers).map(|b| b.id).collect();
let (t, b) = (tools.len(), buffers.len());
tools.sort();
tools.dedup();
buffers.sort();
buffers.dedup();
assert_eq!(tools.len(), t);
assert_eq!(buffers.len(), b);
}
}
+19 -9
View File
@@ -43,6 +43,13 @@ impl Paths {
self.pools_dir().join(pool) self.pools_dir().join(pool)
} }
/// Claudes eigenes Konfigurationsverzeichnis — das, was ohne gesetztes
/// CLAUDE_CONFIG_DIR benutzt wird. Ein Pool kann darauf verweisen, statt
/// ein eigenes Verzeichnis anzulegen (siehe pool::pool_config_dir).
pub(crate) fn default_claude_dir(&self) -> PathBuf {
self.home.join(".claude")
}
/// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein, /// Panel-Dateien pro Projekt: der Skill schreibt seinen Entwurf hier hinein,
/// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel. /// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel.
fn panels_dir(&self) -> PathBuf { fn panels_dir(&self) -> PathBuf {
@@ -58,10 +65,13 @@ fn checked(project: &str) -> &str {
project project
} }
/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als /// Signal-Datei „Notiz öffnen": write_panel (MCP) schreibt die noteId der
/// AI_CONTROL_PANEL in der PTY-Umgebung. /// frisch angelegten Trilium-Notiz, die Fenster öffnen sie daraufhin. Der
pub(crate) fn panel_file(project: &str) -> PathBuf { /// Pfad landet als AI_CONTROL_NOTEOPEN in der PTY-Umgebung.
Paths::real().panels_dir().join(format!("{}.md", checked(project))) pub(crate) fn noteopen_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{}.noteopen.json", checked(project)))
} }
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim /// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
@@ -82,13 +92,13 @@ pub(crate) fn search_file(project: &str) -> PathBuf {
.join(format!("{}.search.json", checked(project))) .join(format!("{}.search.json", checked(project)))
} }
/// Wiki-Puffer eines Projekts (JSON, jeweils letzte Wiki-Seite bzw. letztes /// Persistente ToDo-Liste eines Projekts (JSONL, anhängend — überlebt
/// geöffnetes Dokument — flüchtig, wird beim Session-Start geleert). Der Pfad /// Sessions; write_todos hängt an, Kachel-Löschen entfernt Zeilen). Der Pfad
/// landet als AI_CONTROL_WIKI in der PTY-Umgebung. /// landet als AI_CONTROL_TODOS in der PTY-Umgebung.
pub(crate) fn wiki_file(project: &str) -> PathBuf { pub(crate) fn todos_file(project: &str) -> PathBuf {
Paths::real() Paths::real()
.panels_dir() .panels_dir()
.join(format!("{}.wiki.json", checked(project))) .join(format!("{}.todos.jsonl", checked(project)))
} }
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert. /// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
+280 -79
View File
@@ -22,6 +22,11 @@ pub(crate) struct Pool {
pub(crate) name: String, pub(crate) name: String,
#[serde(rename = "credentialType")] #[serde(rename = "credentialType")]
pub(crate) credential_type: String, pub(crate) credential_type: String,
/// Referenzierter Pool: das CLAUDE_CONFIG_DIR liegt außerhalb, der
/// Pool-Ordner ist dann nur die Hülle für diese pool.json. Home-kontrahiert
/// abgelegt („~/.claude"), damit der Eintrag maschinenübergreifend trägt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) dir: Option<String>,
/// Unbekannte Keys unverändert durchreichen — beim Umbenennen wird die /// Unbekannte Keys unverändert durchreichen — beim Umbenennen wird die
/// ganze pool.json neu geschrieben (dieselbe Fehlerklasse wie ProjectConfig). /// ganze pool.json neu geschrieben (dieselbe Fehlerklasse wie ProjectConfig).
#[serde(flatten)] #[serde(flatten)]
@@ -43,6 +48,35 @@ pub(crate) struct PoolInfo {
pub(crate) running: Vec<String>, pub(crate) running: Vec<String>,
#[serde(rename = "hasCredentials")] #[serde(rename = "hasCredentials")]
pub(crate) has_credentials: bool, pub(crate) has_credentials: bool,
/// Bei referenzierten Pools das fremde Config-Verzeichnis („~/.claude"),
/// sonst leer. Die UI zeigt daran, dass hier nichts der App gehört.
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) dir: Option<String>,
}
/// Das CLAUDE_CONFIG_DIR eines Pools. Normale Pools sind ihr eigenes
/// Config-Verzeichnis; ein referenzierter Pool ist nur eine Hülle mit
/// pool.json und zeigt über `dir` auf ein bestehendes Verzeichnis — typisch
/// claudes Default `~/.claude`, dessen Login damit weiterbenutzt wird.
pub(crate) fn pool_config_dir(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
check_name(pool)?;
Ok(config_dir_of(paths, pool, &read_pool(paths, pool)?))
}
fn config_dir_of(paths: &Paths, pool: &str, p: &Pool) -> PathBuf {
match &p.dir {
Some(d) => crate::domain::paths::expand_home(paths, d),
None => paths.pool_dir(pool),
}
}
/// Referenzierte Pools verwalten fremdes Gut: die App legt dort nichts an,
/// baut nichts um und löscht nichts. Guard für genau diese Operationen.
fn reject_reference(paths: &Paths, pool: &str, was: &str) -> Result<(), String> {
if let Some(dir) = read_pool(paths, pool)?.dir {
return Err(format!("{was} geht bei einem verwiesenen Pool nicht ({dir})"));
}
Ok(())
} }
pub(crate) fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> { pub(crate) fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
@@ -52,33 +86,38 @@ pub(crate) fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display())) serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
} }
/// IDs aller Pool-Ordner: Einträge unter pools/ mit pool.json. Beim ersten
/// Pool existiert pools/ noch nicht — dann ist die Liste leer.
fn pool_ids(paths: &Paths) -> Result<Vec<String>, String> {
let mut out = Vec::new();
if !paths.pools_dir().is_dir() {
return Ok(out);
}
for entry in
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?
{
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
if entry.path().join(POOL_FILE).is_file() {
out.push(entry.file_name().to_string_lossy().into_owned());
}
}
Ok(out)
}
pub(crate) fn list_pools_in( pub(crate) fn list_pools_in(
paths: &Paths, paths: &Paths,
store: &dyn ApikeyStore, store: &dyn ApikeyStore,
) -> Result<Vec<PoolInfo>, String> { ) -> Result<Vec<PoolInfo>, String> {
let mut pools = Vec::new(); let mut pools = Vec::new();
if !paths.pools_dir().is_dir() { for id in pool_ids(paths)? {
return Ok(pools); let pool = read_pool(paths, &id)?;
}
let entries =
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
let cfg_path = entry.path().join(POOL_FILE);
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
let pool: Pool =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
let id = entry.file_name().to_string_lossy().into_owned();
// oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen // oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen
// Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true. // Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true.
// apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei. // apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei.
let has_credentials = match pool.credential_type.as_str() { let has_credentials = match pool.credential_type.as_str() {
"apikey" => { "apikey" => {
store.has(&id)? store.has(&id)?
|| fs::read_to_string(entry.path().join(APIKEY_FILE)) || fs::read_to_string(paths.pool_dir(&id).join(APIKEY_FILE))
.map(|s| !s.trim().is_empty()) .map(|s| !s.trim().is_empty())
.unwrap_or(false) .unwrap_or(false)
} }
@@ -101,33 +140,19 @@ pub(crate) fn list_pools_in(
name: pool.name, name: pool.name,
credential_type: pool.credential_type, credential_type: pool.credential_type,
has_credentials, has_credentials,
dir: pool.dir,
}); });
} }
pools.sort_by(|a, b| a.name.cmp(&b.name)); pools.sort_by(|a, b| a.name.cmp(&b.name));
Ok(pools) Ok(pools)
} }
/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch /// (ID, Anzeigename) aller Pools.
/// nicht — dann ist die Liste leer.
pub(crate) fn pool_names(paths: &Paths) -> Result<Vec<(String, String)>, String> { pub(crate) fn pool_names(paths: &Paths) -> Result<Vec<(String, String)>, String> {
let mut out = Vec::new(); pool_ids(paths)?
if !paths.pools_dir().is_dir() { .into_iter()
return Ok(out); .map(|id| read_pool(paths, &id).map(|p| (id, p.name)))
} .collect()
for entry in
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?
{
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
let cfg_path = entry.path().join(POOL_FILE);
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
let pool: Pool =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
out.push((entry.file_name().to_string_lossy().into_owned(), pool.name));
}
Ok(out)
} }
/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den /// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den
@@ -140,12 +165,37 @@ fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string())) Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string()))
} }
fn write_pool_json(dir: &PathBuf, pool: &Pool) -> Result<(), String> { fn write_pool_json(dir: &std::path::Path, pool: &Pool) -> Result<(), String> {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let raw = serde_json::to_string_pretty(pool).map_err(|e| e.to_string())?; let raw = serde_json::to_string_pretty(pool).map_err(|e| e.to_string())?;
crate::domain::write_atomic(&dir.join(POOL_FILE), &(raw + "\n")) crate::domain::write_atomic(&dir.join(POOL_FILE), &(raw + "\n"))
} }
/// Gemeinsamer Abschluss der Pool-Anlage (apikey wie oauth): pool.json
/// schreiben, bei konfiguriertem poolSyncDir die Runtime verlinken;
/// liefert die Pool-ID.
fn finish_pool_create(
paths: &Paths,
dir: &std::path::Path,
name: &str,
credential_type: &str,
) -> Result<String, String> {
write_pool_json(
dir,
&Pool {
name: name.to_string(),
credential_type: credential_type.into(),
dir: None,
rest: Default::default(),
},
)?;
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?;
}
Ok(id)
}
/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json /// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json
/// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als /// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als
/// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt. /// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt.
@@ -209,42 +259,20 @@ const STALE_PANEL_PERMISSIONS: [&str; 3] =
/// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt /// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt
/// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern. /// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern.
/// Fehler (fehlende/kaputte Datei) bleiben bewusst still: das läuft beim
/// App-Start über alle Pools, auch referenzierte fremde Verzeichnisse.
fn ensure_panel_permission(pool_dir: &std::path::Path) { fn ensure_panel_permission(pool_dir: &std::path::Path) {
let sp = pool_dir.join("settings.json"); let sp = pool_dir.join("settings.json");
let Ok(raw) = fs::read_to_string(&sp) else { let _ = crate::domain::update_settings_permissions(&sp, false, |perms| {
return; let allow = crate::domain::perm_array(perms, "allow")?;
};
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else {
return;
};
let Some(obj) = v.as_object_mut() else { return };
let Some(perms) = obj
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
else {
return;
};
let Some(allow) = perms
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
else {
return;
};
let before = allow.clone();
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s))); allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
for perm in PANEL_PERMISSIONS { for perm in PANEL_PERMISSIONS {
if !allow.iter().any(|e| e.as_str() == Some(perm)) { if !allow.iter().any(|e| e.as_str() == Some(perm)) {
allow.push(serde_json::json!(perm)); allow.push(serde_json::json!(perm));
} }
} }
if *allow == before { Ok(())
return; // nichts geändert });
}
if let Ok(out) = serde_json::to_string_pretty(&v) {
let _ = crate::domain::write_atomic(&sp, &(out + "\n"));
}
} }
/// Registriert den MCP-Server (dieses Binary mit `--mcp-panel`) in der /// Registriert den MCP-Server (dieses Binary mit `--mcp-panel`) in der
@@ -305,10 +333,14 @@ fn provision_pool(pool_dir: &std::path::Path) {
} }
/// Panel-MCP in alle vorhandenen Pools bringen (Migration beim App-Start). /// Panel-MCP in alle vorhandenen Pools bringen (Migration beim App-Start).
/// Bei referenzierten Pools trifft das deren Zielverzeichnis — die drei
/// Schritte sind additiv (Skill-Datei, MCP-Eintrag, Freigabe-Ergänzung).
pub(crate) fn provision_pools_for_panel(paths: &Paths) { pub(crate) fn provision_pools_for_panel(paths: &Paths) {
if let Ok(pools) = pool_names(paths) { if let Ok(pools) = pool_names(paths) {
for (id, _) in pools { for (id, _) in pools {
provision_pool(&paths.pool_dir(&id)); if let Ok(dir) = pool_config_dir(paths, &id) {
provision_pool(&dir);
}
} }
} }
} }
@@ -331,6 +363,9 @@ pub(crate) fn pool_data_dir(paths: &Paths, pool: &str) -> Result<PathBuf, String
/// Vorhandene echte Inhalte werden verworfen (kein History-Erhalt — bewusst). /// Vorhandene echte Inhalte werden verworfen (kein History-Erhalt — bewusst).
pub(crate) fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> { pub(crate) fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
check_name(pool)?; check_name(pool)?;
// Das Umbauen verwirft vorhandene Transkripte/Todos — in einem fremden
// Verzeichnis kommt das nicht in Frage.
reject_reference(paths, pool, "Runtime verlinken")?;
let src = paths.pool_dir(pool); let src = paths.pool_dir(pool);
let data = pool_data_dir(paths, pool)?; let data = pool_data_dir(paths, pool)?;
for (name, is_dir) in SYNCED_RUNTIME { for (name, is_dir) in SYNCED_RUNTIME {
@@ -381,11 +416,7 @@ pub(crate) fn create_apikey_pool_in(
&dir, &dir,
serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }), serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }),
)?; )?;
write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "apikey".into(), rest: Default::default() })?; finish_pool_create(paths, &dir, name, "apikey")
if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?;
}
Ok(id)
} }
/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md) /// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
@@ -395,12 +426,62 @@ pub(crate) fn create_apikey_pool_in(
pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> { pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
let dir = check_new_pool(paths, name)?; let dir = check_new_pool(paths, name)?;
init_pool_config(&dir, serde_json::json!({}))?; init_pool_config(&dir, serde_json::json!({}))?;
write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "oauth".into(), rest: Default::default() })?; finish_pool_create(paths, &dir, name, "oauth")
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?;
} }
Ok(id)
/// Legt einen Pool an, der auf ein bestehendes Config-Verzeichnis verweist —
/// gedacht für claudes Default `~/.claude`, damit ein vorhandener Login ohne
/// erneutes `/login` weiterläuft. Angelegt wird nur die Hülle
/// `pools/<UUID>/pool.json`; im Zielverzeichnis entsteht ausschließlich das,
/// was das Panel braucht (Skill, MCP-Eintrag, Tool-Freigabe) — keine
/// settings.json-Grundausstattung, die fremde Einstellungen überschriebe.
/// Liefert die Pool-ID.
pub(crate) fn create_reference_pool_in(
paths: &Paths,
name: &str,
target: &str,
) -> Result<String, String> {
let hull = check_new_pool(paths, name)?;
let target_dir = crate::domain::paths::expand_home(paths, target);
if !target_dir.is_dir() {
return Err(format!("Verzeichnis gibt es nicht: {}", target_dir.display()));
}
// Ein Ziel innerhalb von pools/ wäre ein Pool im Pool: Löschen und
// Runtime-Symlinks des einen griffen in den anderen.
if target_dir.starts_with(paths.pools_dir()) {
return Err("Verzeichnis liegt in der Pool-Verwaltung".into());
}
if let Some(other) = pool_names(paths)?.iter().find_map(|(id, n)| {
let p = read_pool(paths, id).ok()?;
(config_dir_of(paths, id, &p) == target_dir).then_some(n.clone())
}) {
return Err(format!("Verzeichnis wird schon von Pool {other} benutzt"));
}
let stored = crate::domain::paths::contract_home(paths, &target_dir);
write_pool_json(
&hull,
&Pool {
name: name.to_string(),
credential_type: "oauth".into(),
dir: Some(stored),
rest: Default::default(),
},
)?;
provision_pool(&target_dir);
Ok(hull.file_name().unwrap().to_string_lossy().into_owned())
}
/// Claudes Default-Verzeichnis, wenn es existiert und noch kein Pool darauf
/// zeigt — die UI bietet es dann beim Anlegen als fertigen Pool an.
pub(crate) fn offered_default_dir(paths: &Paths) -> Option<String> {
let dir = paths.default_claude_dir();
if !dir.is_dir() {
return None;
}
let taken = pool_names(paths).ok()?.iter().any(|(id, _)| {
read_pool(paths, id).is_ok_and(|p| config_dir_of(paths, id, &p) == dir)
});
(!taken).then(|| crate::domain::paths::contract_home(paths, &dir))
} }
/// Setzt den Anzeigenamen eines Pools — reines pool.json-Update, ID/Ordner /// Setzt den Anzeigenamen eines Pools — reines pool.json-Update, ID/Ordner
@@ -419,15 +500,15 @@ pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<()
/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung /// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung
/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende /// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende
/// Sessions setzt der delete_pool-Command davor. /// Sessions setzt der delete_pool-Command davor.
/// Bei referenzierten Pools fällt nur die Hülle: das verwiesene Verzeichnis
/// und der Login darin gehören der App nicht.
pub(crate) fn delete_pool_in( pub(crate) fn delete_pool_in(
paths: &Paths, paths: &Paths,
store: &dyn ApikeyStore, store: &dyn ApikeyStore,
name: &str, name: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let dir = paths.pool_dir(name); let dir = paths.pool_dir(name);
if !dir.join(POOL_FILE).is_file() { // Fehlende pool.json scheitert hier — read_pool nennt den Pfad.
return Err(format!("Pool nicht gefunden: {name}"));
}
if read_pool(paths, name)?.credential_type == "apikey" { if read_pool(paths, name)?.credential_type == "apikey" {
store.delete(name)?; store.delete(name)?;
} }
@@ -499,6 +580,126 @@ mod tests {
create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore, create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore,
}; };
/// Ein verwiesener Pool legt nur die Hülle an; im Zielverzeichnis entsteht
/// die Panel-Ausstattung, aber keine settings.json-Grundausstattung — eine
/// vorhandene settings.json bleibt inhaltlich unangetastet.
#[test]
fn referenzpool_laesst_das_zielverzeichnis_stehen() {
let p = tmp_paths();
let ziel = p.default_claude_dir();
fs::create_dir_all(&ziel).unwrap();
fs::write(ziel.join("settings.json"), "{\"model\":\"opus\"}\n").unwrap();
let id = create_reference_pool_in(&p, "System", "~/.claude").unwrap();
// Hülle trägt nur pool.json mit dem Verweis.
let huelle = p.pool_dir(&id);
assert!(huelle.join(POOL_FILE).is_file());
assert!(!huelle.join("settings.json").exists());
assert_eq!(read_pool(&p, &id).unwrap().dir.as_deref(), Some("~/.claude"));
assert_eq!(pool_config_dir(&p, &id).unwrap(), ziel);
// Im Ziel: Panel-Ausstattung dazu, eigene Einstellungen erhalten.
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(ziel.join("settings.json")).unwrap()).unwrap();
assert_eq!(settings["model"], "opus");
assert!(settings["permissions"]["allow"]
.as_array()
.unwrap()
.iter()
.any(|v| v == "mcp__text-panel__write_panel"));
assert!(ziel.join("skills").join("panel").join("SKILL.md").is_file());
}
/// Zeigt der Pool auf claudes Default-Verzeichnis, bleibt CLAUDE_CONFIG_DIR
/// ungesetzt — nur so greift der unsuffixierte Keychain-Eintrag, an dem ein
/// bestehender Login hängt.
#[test]
fn referenzpool_auf_default_setzt_kein_config_dir() {
let p = tmp_paths();
fs::create_dir_all(p.default_claude_dir()).unwrap();
let id = create_reference_pool_in(&p, "System", "~/.claude").unwrap();
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &id).unwrap();
assert_eq!(
crate::domain::project::project_pool_dir_in(&p, "proj").unwrap(),
None
);
}
/// Ein Verweis auf ein anderes Verzeichnis setzt CLAUDE_CONFIG_DIR wie
/// gewohnt — dort hängt der Login am suffixierten Eintrag.
#[test]
fn referenzpool_auf_fremdes_verzeichnis_setzt_config_dir() {
let p = tmp_paths();
let ziel = p.home.join("woanders");
fs::create_dir_all(&ziel).unwrap();
let id = create_reference_pool_in(&p, "Extern", "~/woanders").unwrap();
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &id).unwrap();
assert_eq!(
crate::domain::project::project_pool_dir_in(&p, "proj").unwrap(),
Some(ziel)
);
}
/// Löschen trifft die Hülle, nie das verwiesene Verzeichnis.
#[test]
fn referenzpool_loeschen_laesst_das_ziel_unberuehrt() {
let p = tmp_paths();
let ziel = p.default_claude_dir();
fs::create_dir_all(&ziel).unwrap();
fs::write(ziel.join("settings.json"), "{}\n").unwrap();
let id = create_reference_pool_in(&p, "System", "~/.claude").unwrap();
delete_pool_in(&p, &map_store(), &id).unwrap();
assert!(!p.pool_dir(&id).exists());
assert!(ziel.join("settings.json").is_file());
}
/// Runtime-Symlinks bauen das Verzeichnis um und verwerfen Transkripte —
/// in fremdem Verzeichnis abgelehnt.
#[test]
fn referenzpool_kein_runtime_umbau() {
let p = tmp_paths();
fs::create_dir_all(p.default_claude_dir()).unwrap();
fs::create_dir_all(p.config_dir()).unwrap();
fs::write(
p.config_dir().join(APP_SETTINGS_FILE),
"{\"poolSyncDir\":\"~/sync\"}\n",
)
.unwrap();
let id = create_reference_pool_in(&p, "System", "~/.claude").unwrap();
let err = link_pool_runtime_in(&p, &id).unwrap_err();
assert!(err.contains("~/.claude"), "{err}");
}
/// Zwei Pools auf dasselbe Verzeichnis wären zwei Namen für einen Login.
#[test]
fn referenzpool_nur_einmal_pro_verzeichnis() {
let p = tmp_paths();
fs::create_dir_all(p.default_claude_dir()).unwrap();
create_reference_pool_in(&p, "System", "~/.claude").unwrap();
assert!(offered_default_dir(&p).is_none());
let err = create_reference_pool_in(&p, "Nochmal", "~/.claude").unwrap_err();
assert!(err.contains("System"), "{err}");
}
/// Ohne Verzeichnis kein Angebot und kein Pool.
#[test]
fn referenzpool_braucht_ein_vorhandenes_verzeichnis() {
let p = tmp_paths();
assert_eq!(offered_default_dir(&p), None);
assert!(create_reference_pool_in(&p, "System", "~/.claude").is_err());
fs::create_dir_all(p.default_claude_dir()).unwrap();
assert_eq!(offered_default_dir(&p).as_deref(), Some("~/.claude"));
}
#[test] #[test]
fn apikey_pool_anlegen() { fn apikey_pool_anlegen() {
let p = tmp_paths(); let p = tmp_paths();
+115 -202
View File
@@ -38,6 +38,8 @@ pub(crate) struct Project {
pub(crate) pool: Option<String>, pub(crate) pool: Option<String>,
pub(crate) running: bool, pub(crate) running: bool,
pub(crate) terminal: TerminalConfig, pub(crate) terminal: TerminalConfig,
/// Trilium-Modul aktiv — die Projektliste zeigt dann den Archiv-Knopf.
pub(crate) archive: bool,
} }
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
@@ -51,16 +53,11 @@ pub(crate) struct ProjectConfig {
pub(crate) name: Option<String>, pub(crate) name: Option<String>,
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")] #[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
pub(crate) terminal: TerminalConfig, pub(crate) terminal: TerminalConfig,
/// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen /// Modul-Abweichungen vom Default (`"commands": false`); fehlender Key =
/// (~-relativ gespeichert). Liest den früheren Key `archiveDir` weiterhin ein, /// Default aus der Registry (domain/modules.rs). Kern-Module ignorieren
/// geschrieben wird nur noch `archiveHome`. /// den Eintrag.
#[serde( #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
default, pub(crate) modules: std::collections::BTreeMap<String, bool>,
rename = "archiveHome",
alias = "archiveDir",
skip_serializing_if = "Option::is_none"
)]
pub(crate) archive_home: Option<String>,
/// Alle Keys, die dieser Build nicht kennt — unverändert durchgereicht. /// Alle Keys, die dieser Build nicht kennt — unverändert durchgereicht.
/// Ohne das verliert jeder read-modify-write (Pool-Zuweisung, Terminal- /// Ohne das verliert jeder read-modify-write (Pool-Zuweisung, Terminal-
/// Einstellungen) still die Felder neuerer Versionen: serde verwirft /// Einstellungen) still die Felder neuerer Versionen: serde verwirft
@@ -93,15 +90,18 @@ pub(crate) fn settings_path(dir: &std::path::Path) -> PathBuf {
dir.join(".claude").join("settings.json") dir.join(".claude").join("settings.json")
} }
pub(crate) fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE))
}
pub(crate) fn read_project_config_in( pub(crate) fn read_project_config_in(
paths: &Paths, paths: &Paths,
project: &str, project: &str,
) -> Result<ProjectConfig, String> { ) -> Result<ProjectConfig, String> {
let cfg_path = project_config_path(paths, project)?; read_config_at(&project_dir(paths, project)?)
}
/// config.json unter `<dir>/.ai-control` lesen; ohne Datei der Default.
/// Für Fälle, in denen der Ordner vor der Registrierung steht (Import,
/// Migration) — sonst über `read_project_config_in`.
pub(crate) fn read_config_at(dir: &std::path::Path) -> Result<ProjectConfig, String> {
let cfg_path = dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE);
if !cfg_path.is_file() { if !cfg_path.is_file() {
return Ok(ProjectConfig::default()); return Ok(ProjectConfig::default());
} }
@@ -166,6 +166,7 @@ pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
path: contract_home(paths, &entry.dir), path: contract_home(paths, &entry.dir),
pool: entry.pool, pool: entry.pool,
name: cfg.name.unwrap_or_else(|| id.clone()), name: cfg.name.unwrap_or_else(|| id.clone()),
archive: cfg.modules.get("trilium").copied().unwrap_or(false),
terminal: cfg.terminal, terminal: cfg.terminal,
id, id,
}); });
@@ -218,7 +219,14 @@ pub(crate) fn project_pool_dir_in(
return Ok(None); return Ok(None);
}; };
check_name(&pool).map_err(|_| format!("ungültiger Pool in der Registry: {pool}"))?; check_name(&pool).map_err(|_| format!("ungültiger Pool in der Registry: {pool}"))?;
Ok(Some(paths.pool_dir(&pool))) let dir = crate::domain::pool::pool_config_dir(paths, &pool)?;
// Zeigt der Pool auf claudes Default-Verzeichnis, bleibt CLAUDE_CONFIG_DIR
// ungesetzt: claude nimmt dann von sich aus dieses Verzeichnis — und den
// unsuffixierten Keychain-Eintrag, an dem ein bestehender Login hängt.
if dir == paths.default_claude_dir() {
return Ok(None);
}
Ok(Some(dir))
} }
/// Projekt-Config für den Terminal-Prozess (Terminal-Einstellungen + Name). /// Projekt-Config für den Terminal-Prozess (Terminal-Einstellungen + Name).
@@ -231,7 +239,6 @@ pub(crate) fn project_config(project: &str) -> Result<ProjectConfig, String> {
/// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit /// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit
/// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter /// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter
/// ~/claude-projects/<name>. Liefert die neue Projekt-ID. /// ~/claude-projects/<name>. Liefert die neue Projekt-ID.
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_project_full_in( pub(crate) fn create_project_full_in(
paths: &Paths, paths: &Paths,
name: &str, name: &str,
@@ -240,7 +247,6 @@ pub(crate) fn create_project_full_in(
work_dir: Option<&str>, work_dir: Option<&str>,
create_work_dir: bool, create_work_dir: bool,
terminal: TerminalConfig, terminal: TerminalConfig,
todo: bool,
) -> Result<String, String> { ) -> Result<String, String> {
check_name(name)?; check_name(name)?;
let dir = match dir { let dir = match dir {
@@ -301,13 +307,10 @@ pub(crate) fn create_project_full_in(
id: Some(id.clone()), id: Some(id.clone()),
name: Some(name.to_string()), name: Some(name.to_string()),
terminal, terminal,
archive_home: None, modules: Default::default(),
rest: Default::default(), rest: Default::default(),
}; };
write_project_config_in(paths, &id, &cfg)?; write_project_config_in(paths, &id, &cfg)?;
if todo {
crate::domain::todo::set_todo_in(paths, &id, true)?;
}
crate::platform::write_terminal_desktop(paths, &id, &cfg); crate::platform::write_terminal_desktop(paths, &id, &cfg);
Ok(id) Ok(id)
} }
@@ -377,13 +380,7 @@ pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
.into_owned(); .into_owned();
check_name(&dirname)?; check_name(&dirname)?;
let cfg_path = dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE); let mut cfg = read_config_at(&dir)?;
let mut cfg: ProjectConfig = if cfg_path.is_file() {
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?
} else {
ProjectConfig::default()
};
if let Some(id) = cfg.id.as_deref() { if let Some(id) = cfg.id.as_deref() {
if load_registry(paths)?.contains_key(id) { if load_registry(paths)?.contains_key(id) {
return Err(format!("Projekt ist schon registriert: {id}")); return Err(format!("Projekt ist schon registriert: {id}"));
@@ -398,11 +395,6 @@ pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
let wd_path = expand_home(paths, &wd); let wd_path = expand_home(paths, &wd);
fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?; fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
} }
if let Some(archive) = cfg.archive_home.as_deref() {
let expanded = expand_home(paths, archive);
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
crate::domain::archive::add_archive_permission(paths, &id, archive)?;
}
crate::platform::write_terminal_desktop(paths, &id, &cfg); crate::platform::write_terminal_desktop(paths, &id, &cfg);
Ok(()) Ok(())
} }
@@ -418,37 +410,16 @@ pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<()
} }
let dir = contract_home(paths, &wd_path); let dir = contract_home(paths, &wd_path);
let sp = settings_path(&project_dir(paths, name)?); let sp = settings_path(&project_dir(paths, name)?);
let mut v: serde_json::Value = if sp.is_file() { crate::domain::update_settings_permissions(&sp, true, |perms| {
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; let dirs = crate::domain::perm_array(perms, "additionalDirectories")?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
} else {
serde_json::json!({})
};
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
let perms = root
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or("permissions ist kein Objekt")?;
let dirs = perms
.entry("additionalDirectories")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("additionalDirectories ist kein Array")?;
if dirs.iter().any(|d| d.as_str() == Some(&dir)) { if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
return Err(format!("schon eingetragen: {dir}")); return Err(format!("schon eingetragen: {dir}"));
} }
dirs.push(serde_json::json!(dir)); dirs.push(serde_json::json!(dir));
let allow = perms let allow = crate::domain::perm_array(perms, "allow")?;
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("allow ist kein Array")?;
allow.insert(0, serde_json::json!(format!("Edit({dir}/**)"))); allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?; Ok(())
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; })
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
} }
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und /// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
@@ -456,15 +427,8 @@ pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<()
pub(crate) fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> { pub(crate) fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
check_name(name)?; check_name(name)?;
let sp = settings_path(&project_dir(paths, name)?); let sp = settings_path(&project_dir(paths, name)?);
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?; crate::domain::update_settings_permissions(&sp, false, |perms| {
let mut v: serde_json::Value = let dirs = crate::domain::perm_array(perms, "additionalDirectories")?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
let perms = v["permissions"]
.as_object_mut()
.ok_or("permissions ist kein Objekt")?;
let dirs = perms["additionalDirectories"]
.as_array_mut()
.ok_or("additionalDirectories ist kein Array")?;
let before = dirs.len(); let before = dirs.len();
dirs.retain(|d| d.as_str() != Some(dir)); dirs.retain(|d| d.as_str() != Some(dir));
if dirs.len() == before { if dirs.len() == before {
@@ -473,8 +437,8 @@ pub(crate) fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) { if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)"))); allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
} }
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?; Ok(())
crate::domain::write_atomic(&sp, &(raw + "\n")) })
} }
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json. /// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
@@ -507,11 +471,10 @@ pub(crate) struct DeletePreview {
pub(crate) name: String, pub(crate) name: String,
pub(crate) project_dir: String, pub(crate) project_dir: String,
pub(crate) ai_control_dir: bool, pub(crate) ai_control_dir: bool,
pub(crate) archive_permission: bool,
pub(crate) todo_hook: bool,
pub(crate) panel_files: usize, pub(crate) panel_files: usize,
pub(crate) archive_home: Option<String>, /// Trilium-Datenverzeichnis der Projekt-Instanz (~-relativ), sofern es
pub(crate) archive_docs: usize, /// existiert — das Archiv der Lösch-Stufe "archive".
pub(crate) trilium_data: Option<String>,
pub(crate) work_dirs: Vec<String>, pub(crate) work_dirs: Vec<String>,
} }
@@ -519,48 +482,36 @@ pub(crate) fn delete_preview_in(paths: &Paths, project: &str) -> Result<DeletePr
check_name(project)?; check_name(project)?;
let dir = project_dir(paths, project)?; let dir = project_dir(paths, project)?;
let cfg = read_project_config_in(paths, project)?; let cfg = read_project_config_in(paths, project)?;
let archive_docs = match cfg.archive_home.as_deref() { let tri = crate::domain::trilium::data_dir(paths, project);
Some(a) => {
let home = expand_home(paths, a);
if home.is_dir() {
crate::domain::archive_index::scan_archive(&home)?.len()
} else {
0
}
}
None => 0,
};
Ok(DeletePreview { Ok(DeletePreview {
name: cfg.name.clone().unwrap_or_else(|| project.to_string()), name: cfg.name.clone().unwrap_or_else(|| project.to_string()),
project_dir: contract_home(paths, &dir), project_dir: contract_home(paths, &dir),
ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_dir(), ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_dir(),
archive_permission: cfg.archive_home.is_some(),
todo_hook: crate::domain::todo::todo_state_in(paths, project)?,
panel_files: session_files(paths, project).iter().filter(|f| f.is_file()).count(), panel_files: session_files(paths, project).iter().filter(|f| f.is_file()).count(),
archive_home: cfg.archive_home.clone(), trilium_data: tri.is_dir().then(|| contract_home(paths, &tri)),
archive_docs,
work_dirs: project_work_dirs_in(paths, project)?, work_dirs: project_work_dirs_in(paths, project)?,
}) })
} }
/// Flüchtige Panel-Kanaldateien des Projekts unter ~/.config/ai-control/panels. /// Flüchtige Panel-Kanaldateien des Projekts unter ~/.config/ai-control/panels.
fn session_files(paths: &Paths, project: &str) -> [PathBuf; 4] { /// Panel-Kanaldateien des Projekts — aus der Modul-Registry, damit neue
/// Puffer automatisch in Löschvorschau und Löschung landen.
fn session_files(paths: &Paths, project: &str) -> Vec<PathBuf> {
let panels = paths.config_dir().join("panels"); let panels = paths.config_dir().join("panels");
[ crate::domain::modules::MODULES
panels.join(format!("{project}.md")), .iter()
panels.join(format!("{project}.commands.jsonl")), .flat_map(|m| m.buffers)
panels.join(format!("{project}.search.json")), .map(|b| panels.join(format!("{project}.{}", b.suffix)))
panels.join(format!("{project}.wiki.json")), .collect()
]
} }
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die /// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
/// vorige ein): /// vorige ein):
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/, /// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
/// Archiv-Rechte und Todo-Hook in der settings.json, Panel-Dateien, /// Panel-Dateien, .desktop. Ordner, memory/ und das Claude-Code-Grundgerüst
/// .desktop. Ordner, memory/ und das Claude-Code-Grundgerüst der /// der settings.json bleiben.
/// settings.json bleiben. /// - "archive": zusätzlich das Trilium-Archiv der Projekt-Instanz
/// - "archive": zusätzlich der Archiv-Ordner samt Dokumenten. /// (Datenverzeichnis + Keyring-Token; die Instanz wird vorher gestoppt).
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag. /// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
pub(crate) fn delete_project_scoped_in( pub(crate) fn delete_project_scoped_in(
paths: &Paths, paths: &Paths,
@@ -573,27 +524,17 @@ pub(crate) fn delete_project_scoped_in(
if !dir.is_dir() { if !dir.is_dir() {
return Err(format!("Projektordner nicht gefunden: {}", dir.display())); return Err(format!("Projektordner nicht gefunden: {}", dir.display()));
} }
let cfg = read_project_config_in(paths, project)?;
// Archiv vor der Integration räumen — Stufe 1 löscht die Config, die den
// Pfad kennt.
if scope == "archive" || scope == "full" { if scope == "archive" || scope == "full" {
if let Some(a) = cfg.archive_home.as_deref() { crate::domain::trilium::stop_instance(project);
let home = expand_home(paths, a); let tri = crate::domain::trilium::data_dir(paths, project);
if home.is_dir() { if tri.is_dir() {
fs::remove_dir_all(&home).map_err(|e| format!("{}: {e}", home.display()))?; fs::remove_dir_all(&tri).map_err(|e| format!("{}: {e}", tri.display()))?;
}
} }
crate::domain::trilium::delete_token(project);
} }
match scope { match scope {
"integration" | "archive" => { "integration" | "archive" => {
if let Some(a) = cfg.archive_home.as_deref() {
crate::domain::archive::remove_archive_permission(paths, project, a)?;
}
if crate::domain::todo::todo_state_in(paths, project)? {
crate::domain::todo::set_todo_in(paths, project, false)?;
}
let ac = dir.join(PROJECT_CONFIG_DIR); let ac = dir.join(PROJECT_CONFIG_DIR);
if ac.is_dir() { if ac.is_dir() {
fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?; fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?;
@@ -646,20 +587,8 @@ pub(crate) fn set_terminal_config_in(
// Dateiname speichern — das Icon synct damit mit dem Projekt. // Dateiname speichern — das Icon synct damit mit dem Projekt.
if let Some(icon) = terminal.icon.as_deref() { if let Some(icon) = terminal.icon.as_deref() {
if icon.starts_with('/') { if icon.starts_with('/') {
let src = PathBuf::from(icon);
let ext = src
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let name = format!("icon.{ext}");
let cfg_dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR); let cfg_dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR);
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?; terminal.icon = Some(adopt_icon(std::path::Path::new(icon), &cfg_dir)?);
let dest = cfg_dir.join(&name);
if src != dest {
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
}
terminal.icon = Some(name);
} }
} }
// Die Terminal-Config kommt aus der Oberfläche und kennt nur die drei Felder; // Die Terminal-Config kommt aus der Oberfläche und kennt nur die drei Felder;
@@ -671,6 +600,23 @@ pub(crate) fn set_terminal_config_in(
Ok(()) Ok(())
} }
/// Icon-Datei als `icon.<ext>` (kleingeschrieben, Default png) in den
/// Projekt-Config-Ordner kopieren; liefert den gespeicherten Dateinamen.
fn adopt_icon(src: &std::path::Path, cfg_dir: &std::path::Path) -> Result<String, String> {
let ext = src
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let name = format!("icon.{ext}");
fs::create_dir_all(cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
let dest = cfg_dir.join(&name);
if src != dest {
fs::copy(src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
}
Ok(name)
}
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im /// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
/// Projekt-Config-Ordner (.ai-control) des Projekts. /// Projekt-Config-Ordner (.ai-control) des Projekts.
pub(crate) fn resolve_icon_path( pub(crate) fn resolve_icon_path(
@@ -687,7 +633,7 @@ pub(crate) fn resolve_icon_path(
/// Liest eine Datei für `write_panel(path)` — den promptfreien MCP-Weg. /// Liest eine Datei für `write_panel(path)` — den promptfreien MCP-Weg.
/// Erlaubt ist genau, was die Session über ihre Permissions ohnehin promptfrei /// Erlaubt ist genau, was die Session über ihre Permissions ohnehin promptfrei
/// liest: Projektordner, Arbeitsordner und Archiv-Home. Geprüft wird nach /// liest: Projektordner und Arbeitsordner. Geprüft wird nach
/// `canonicalize` (symlink-fest), nur reguläre Dateien bis 2 MB — sonst /// `canonicalize` (symlink-fest), nur reguläre Dateien bis 2 MB — sonst
/// umginge das Tool das Read-Permission-Modell von Claude Code, und ein /// umginge das Tool das Read-Permission-Modell von Claude Code, und ein
/// Device wie /dev/zero hinge den Prozess. /// Device wie /dev/zero hinge den Prozess.
@@ -702,16 +648,13 @@ pub(crate) fn read_for_panel_in(
for d in project_work_dirs_in(paths, project)? { for d in project_work_dirs_in(paths, project)? {
roots.push(expand_home(paths, &d)); roots.push(expand_home(paths, &d));
} }
if let Some(a) = read_project_config_in(paths, project)?.archive_home {
roots.push(expand_home(paths, &a));
}
let allowed = roots let allowed = roots
.iter() .iter()
.filter_map(|r| fs::canonicalize(r).ok()) .filter_map(|r| fs::canonicalize(r).ok())
.any(|r| canon.starts_with(&r)); .any(|r| canon.starts_with(&r));
if !allowed { if !allowed {
return Err(format!( return Err(format!(
"{src}: liegt außerhalb von Projekt-, Arbeits- und Archiv-Ordnern" "{src}: liegt außerhalb von Projekt- und Arbeitsordnern"
)); ));
} }
let f = fs::File::open(&canon).map_err(|e| format!("{src}: {e}"))?; let f = fs::File::open(&canon).map_err(|e| format!("{src}: {e}"))?;
@@ -764,13 +707,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
// war der Anzeigename. // war der Anzeigename.
let cfg_dir = entry.dir.join(PROJECT_CONFIG_DIR); let cfg_dir = entry.dir.join(PROJECT_CONFIG_DIR);
let cfg_path = cfg_dir.join(PROJECT_FILE); let cfg_path = cfg_dir.join(PROJECT_FILE);
let mut cfg: ProjectConfig = if cfg_path.is_file() { let mut cfg = read_config_at(&entry.dir)?;
let raw =
fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?
} else {
ProjectConfig::default()
};
let mut cfg_dirty = cfg.id.is_none() || cfg.name.is_none(); let mut cfg_dirty = cfg.id.is_none() || cfg.name.is_none();
let id = cfg.id.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone(); let id = cfg.id.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone();
cfg.name.get_or_insert_with(|| key.clone()); cfg.name.get_or_insert_with(|| key.clone());
@@ -782,16 +719,8 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
if !icon.starts_with('/') { if !icon.starts_with('/') {
let old = paths.icons_dir().join(&icon); let old = paths.icons_dir().join(&icon);
if old.is_file() { if old.is_file() {
let ext = std::path::Path::new(&icon) cfg.terminal.icon = Some(adopt_icon(&old, &cfg_dir)?);
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let name = format!("icon.{ext}");
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
fs::copy(&old, cfg_dir.join(&name)).map_err(|e| format!("{}: {e}", old.display()))?;
fs::remove_file(&old).map_err(|e| format!("{}: {e}", old.display()))?; fs::remove_file(&old).map_err(|e| format!("{}: {e}", old.display()))?;
cfg.terminal.icon = Some(name);
cfg_dirty = true; cfg_dirty = true;
} }
} }
@@ -817,7 +746,10 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
mod tests { mod tests {
use super::*; use super::*;
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths}; use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
use crate::domain::todo::TODO_FILE;
fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE))
}
/// Ein Pool-Eintrag mit Pfad-Bestandteilen — etwa per Migration aus der /// Ein Pool-Eintrag mit Pfad-Bestandteilen — etwa per Migration aus der
/// mitgeklonten Alt-Datei in die Registry gelangt — darf kein beliebiges /// mitgeklonten Alt-Datei in die Registry gelangt — darf kein beliebiges
@@ -870,7 +802,6 @@ mod tests {
title: Some("Neu".into()), title: Some("Neu".into()),
..Default::default() ..Default::default()
}, },
true,
) )
.unwrap(); .unwrap();
@@ -905,21 +836,14 @@ mod tests {
"Edit(~/claude-projects/neu/**)" "Edit(~/claude-projects/neu/**)"
); );
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu"); assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
// todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da assert!(settings.get("hooks").is_none());
assert!(dir.join(TODO_FILE).is_file());
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(groups.len(), 1);
assert!(groups[0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(TODO_FILE));
} }
#[test] #[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() { fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths(); let p = tmp_paths();
let id = let id =
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false) create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default())
.unwrap(); .unwrap();
let dir = p.projects_dir().join("neu"); let dir = p.projects_dir().join("neu");
assert!(dir.join(".claude").join("settings.json").is_file()); assert!(dir.join(".claude").join("settings.json").is_file());
@@ -944,7 +868,7 @@ mod tests {
let p = tmp_paths(); let p = tmp_paths();
create_project(&p, "neu").unwrap(); create_project(&p, "neu").unwrap();
let err = let err =
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false) create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default())
.unwrap_err(); .unwrap_err();
assert!(err.contains("existiert bereits")); assert!(err.contains("existiert bereits"));
} }
@@ -960,7 +884,6 @@ mod tests {
Some("~/projects/gibtsnicht"), Some("~/projects/gibtsnicht"),
false, false,
TerminalConfig::default(), TerminalConfig::default(),
false,
) )
.unwrap_err(); .unwrap_err();
assert!(err.contains("Arbeitsverzeichnis fehlt")); assert!(err.contains("Arbeitsverzeichnis fehlt"));
@@ -1131,7 +1054,8 @@ mod tests {
let cfg = read_project_config_in(&p, &id).unwrap(); let cfg = read_project_config_in(&p, &id).unwrap();
assert_eq!(cfg.id.as_deref(), Some(id.as_str())); assert_eq!(cfg.id.as_deref(), Some(id.as_str()));
assert_eq!(cfg.name.as_deref(), Some("proj")); assert_eq!(cfg.name.as_deref(), Some("proj"));
assert_eq!(cfg.archive_home.as_deref(), Some("~/archiv")); // Unbekannte Alt-Keys (archiveHome) wandern unverändert mit.
assert_eq!(cfg.rest["archiveHome"], "~/archiv");
// Icon liegt jetzt im Projekt, das zentrale Verzeichnis ist geräumt. // Icon liegt jetzt im Projekt, das zentrale Verzeichnis ist geräumt.
assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png")); assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png"));
assert!(dir.join(PROJECT_CONFIG_DIR).join("icon.png").is_file()); assert!(dir.join(PROJECT_CONFIG_DIR).join("icon.png").is_file());
@@ -1186,8 +1110,7 @@ mod tests {
} }
/// Import eines Ordners mit mitgebrachter Config: die ID wird übernommen /// Import eines Ordners mit mitgebrachter Config: die ID wird übernommen
/// (Sync-Fall), Arbeitsordner und Archiv-Home werden angelegt, die /// (Sync-Fall), Arbeitsordner werden angelegt.
/// Archiv-Permission nachgezogen.
#[test] #[test]
fn projekt_import_uebernimmt_id_und_legt_pfade_an() { fn projekt_import_uebernimmt_id_und_legt_pfade_an() {
let p = tmp_paths(); let p = tmp_paths();
@@ -1195,7 +1118,7 @@ mod tests {
fs::create_dir_all(dir.join(PROJECT_CONFIG_DIR)).unwrap(); fs::create_dir_all(dir.join(PROJECT_CONFIG_DIR)).unwrap();
fs::write( fs::write(
dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE), dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE),
r#"{"id": "sync-id", "name": "Mein Projekt", "archiveHome": "~/archiv/proj"}"#, r#"{"id": "sync-id", "name": "Mein Projekt"}"#,
) )
.unwrap(); .unwrap();
fs::create_dir_all(dir.join(".claude")).unwrap(); fs::create_dir_all(dir.join(".claude")).unwrap();
@@ -1210,10 +1133,6 @@ mod tests {
assert_eq!(project_dir(&p, "sync-id").unwrap(), dir); assert_eq!(project_dir(&p, "sync-id").unwrap(), dir);
assert_eq!(display_name_in(&p, "sync-id").unwrap(), "Mein Projekt"); assert_eq!(display_name_in(&p, "sync-id").unwrap(), "Mein Projekt");
assert!(p.home.join("work/proj").is_dir()); assert!(p.home.join("work/proj").is_dir());
assert!(p.home.join("archiv/proj").is_dir());
let raw = fs::read_to_string(settings_path(&dir)).unwrap();
assert!(raw.contains("~/archiv/proj"));
assert!(raw.contains("Edit(~/archiv/proj/**)"));
// Doppelt aufnehmen scheitert an der ID. // Doppelt aufnehmen scheitert an der ID.
let err = add_project_in(&p, &dir.display().to_string()).unwrap_err(); let err = add_project_in(&p, &dir.display().to_string()).unwrap_err();
@@ -1254,7 +1173,7 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_laesst_arbeitsordner() { fn projekt_loeschen_laesst_arbeitsordner() {
let p = tmp_paths(); let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap(); .unwrap();
delete_project_scoped_in(&p, &id, "full", false).unwrap(); delete_project_scoped_in(&p, &id, "full", false).unwrap();
assert!(!p.projects_dir().join("proj").exists()); assert!(!p.projects_dir().join("proj").exists());
@@ -1264,7 +1183,7 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_mit_arbeitsordner() { fn projekt_loeschen_mit_arbeitsordner() {
let p = tmp_paths(); let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap(); .unwrap();
delete_project_scoped_in(&p, &id, "full", true).unwrap(); delete_project_scoped_in(&p, &id, "full", true).unwrap();
assert!(!p.projects_dir().join("proj").exists()); assert!(!p.projects_dir().join("proj").exists());
@@ -1274,7 +1193,7 @@ mod tests {
#[test] #[test]
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() { fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
let p = tmp_paths(); let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap(); .unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap(); fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_scoped_in(&p, &id, "full", true).is_err()); assert!(delete_project_scoped_in(&p, &id, "full", true).is_err());
@@ -1283,7 +1202,7 @@ mod tests {
#[test] #[test]
fn projekt_arbeitsordner_auslesen() { fn projekt_arbeitsordner_auslesen() {
let p = tmp_paths(); let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false) let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default())
.unwrap(); .unwrap();
assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/proj"]); assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/proj"]);
// Projekt ohne settings.json → leer // Projekt ohne settings.json → leer
@@ -1301,7 +1220,7 @@ mod tests {
#[test] #[test]
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() { fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
let p = tmp_paths(); let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap(); .unwrap();
fs::create_dir_all(p.home.join("projects/extra")).unwrap(); fs::create_dir_all(p.home.join("projects/extra")).unwrap();
let picked = p.home.join("projects/extra").to_string_lossy().into_owned(); let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
@@ -1323,7 +1242,7 @@ mod tests {
#[test] #[test]
fn projektordner_verlegen() { fn projektordner_verlegen() {
let p = tmp_paths(); let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap(); .unwrap();
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu. // Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
let new_dir = p.home.join("elsewhere").join("proj"); let new_dir = p.home.join("elsewhere").join("proj");
@@ -1343,7 +1262,7 @@ mod tests {
// -- write_panel(path): promptfreier Lese-Weg -- // -- write_panel(path): promptfreier Lese-Weg --
#[test] #[test]
fn panel_lesen_nur_aus_projekt_arbeits_und_archivordnern() { fn panel_lesen_nur_aus_projekt_und_arbeitsordnern() {
let p = tmp_paths(); let p = tmp_paths();
create_project(&p, "proj").unwrap(); create_project(&p, "proj").unwrap();
let dir = p.projects_dir().join("proj"); let dir = p.projects_dir().join("proj");
@@ -1379,54 +1298,48 @@ mod tests {
#[test] #[test]
fn loeschen_nur_integration() { fn loeschen_nur_integration() {
let p = tmp_paths(); let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), true) let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap(); .unwrap();
let dir = p.projects_dir().join("proj"); let dir = p.projects_dir().join("proj");
// Archiv-Home samt Permission wie über die UI gesetzt // flüchtige Panel-Datei + Trilium-Datenverzeichnis
let mut cfg = read_project_config_in(&p, &id).unwrap();
cfg.archive_home = Some("~/archiv/proj".into());
write_project_config_in(&p, &id, &cfg).unwrap();
crate::domain::archive::add_archive_permission(&p, &id, "~/archiv/proj").unwrap();
fs::create_dir_all(p.home.join("archiv/proj")).unwrap();
fs::write(p.home.join("archiv/proj/doc.md"), "inhalt").unwrap();
// flüchtige Panel-Datei
fs::create_dir_all(p.config_dir().join("panels")).unwrap(); fs::create_dir_all(p.config_dir().join("panels")).unwrap();
fs::write(p.config_dir().join("panels").join(format!("{id}.md")), "x").unwrap(); fs::write(
p.config_dir().join("panels").join(format!("{id}.commands.jsonl")),
"x",
)
.unwrap();
let tri = crate::domain::trilium::data_dir(&p, &id);
fs::create_dir_all(&tri).unwrap();
let preview = delete_preview_in(&p, &id).unwrap(); let preview = delete_preview_in(&p, &id).unwrap();
assert!(preview.ai_control_dir && preview.archive_permission && preview.todo_hook); assert!(preview.ai_control_dir);
assert_eq!(preview.panel_files, 1); assert_eq!(preview.panel_files, 1);
assert_eq!(preview.archive_docs, 1); assert!(preview.trilium_data.is_some());
delete_project_scoped_in(&p, &id, "integration", false).unwrap(); delete_project_scoped_in(&p, &id, "integration", false).unwrap();
assert!(dir.is_dir()); assert!(dir.is_dir());
assert!(!dir.join(PROJECT_CONFIG_DIR).exists()); assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
let sp = fs::read_to_string(settings_path(&dir)).unwrap(); let sp = fs::read_to_string(settings_path(&dir)).unwrap();
assert!(!sp.contains("archiv/proj"));
assert!(!sp.contains("OFFENE-PUNKTE.md")); // Hook weg (leere Hook-Liste darf bleiben)
assert!(sp.contains("autoMemoryDirectory")); // Claude-Code-Bestand bleibt assert!(sp.contains("autoMemoryDirectory")); // Claude-Code-Bestand bleibt
assert!(dir.join(crate::domain::todo::TODO_FILE).is_file()); // Nutzerinhalt bleibt assert!(tri.is_dir()); // Archiv bleibt
assert!(p.home.join("archiv/proj/doc.md").is_file()); // Archiv bleibt assert!(!p.config_dir().join("panels").join(format!("{id}.commands.jsonl")).exists());
assert!(!p.config_dir().join("panels").join(format!("{id}.md")).exists());
assert!(load_registry(&p).unwrap().is_empty()); assert!(load_registry(&p).unwrap().is_empty());
} }
/// Stufe „Integration & Archiv": zusätzlich stirbt der Archiv-Ordner. /// Stufe „Integration & Archiv": zusätzlich stirbt das Trilium-Datadir.
#[test] #[test]
fn loeschen_integration_und_archiv() { fn loeschen_integration_und_archiv() {
let p = tmp_paths(); let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false) let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
.unwrap(); .unwrap();
let mut cfg = read_project_config_in(&p, &id).unwrap(); let tri = crate::domain::trilium::data_dir(&p, &id);
cfg.archive_home = Some("~/archiv/proj".into()); fs::create_dir_all(&tri).unwrap();
write_project_config_in(&p, &id, &cfg).unwrap();
fs::create_dir_all(p.home.join("archiv/proj")).unwrap();
delete_project_scoped_in(&p, &id, "archive", false).unwrap(); delete_project_scoped_in(&p, &id, "archive", false).unwrap();
assert!(p.projects_dir().join("proj").is_dir()); assert!(p.projects_dir().join("proj").is_dir());
assert!(!p.home.join("archiv/proj").exists()); assert!(!tri.exists());
assert!(load_registry(&p).unwrap().is_empty()); assert!(load_registry(&p).unwrap().is_empty());
} }
+12 -6
View File
@@ -1,5 +1,5 @@
//! App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen): //! App-eigene settings.json unter ~/.config/ai-control (nicht pool-/projektbezogen):
//! claudeCommand, syncOnSessionEnd, poolSyncDir, terminalFontSize, spellcheckLang. //! claudeCommand, syncOnSessionEnd, poolSyncDir, terminalFontSize.
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -53,12 +53,18 @@ pub(crate) fn set_terminal_font_size_in(paths: &Paths, size: u32) -> Result<(),
write_app_setting(paths, "terminalFontSize", serde_json::json!(size)) write_app_setting(paths, "terminalFontSize", serde_json::json!(size))
} }
/// Standard-Sprache der Rechtschreibprüfung (settings.json: spellcheckLang), /// Hat der Nutzer das Angebot, die App nach ~/Applications zu holen, schon
/// Default „de". Pro Text im Panel überschreibbar. /// abgelehnt? (settings.json: moveOfferDismissed, nur macOS)
pub(crate) fn spellcheck_lang(paths: &Paths) -> String { #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn move_offer_dismissed(paths: &Paths) -> bool {
read_app_settings(paths) read_app_settings(paths)
.and_then(|v| v["spellcheckLang"].as_str().map(str::to_string)) .and_then(|v| v["moveOfferDismissed"].as_bool())
.unwrap_or_else(|| "de".to_string()) .unwrap_or(false)
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn set_move_offer_dismissed_in(paths: &Paths, dismissed: bool) -> Result<(), String> {
write_app_setting(paths, "moveOfferDismissed", serde_json::json!(dismissed))
} }
/// Einen Schlüssel setzen; übrige App-settings bleiben erhalten. /// Einen Schlüssel setzen; übrige App-settings bleiben erhalten.
-166
View File
@@ -1,166 +0,0 @@
//! Feature: Todoliste. Muster robotunits: Datei im Projekt-Root, per
//! SessionStart-Hook (jq) als additionalContext in jede Session injiziert.
use std::fs;
use crate::domain::check_name;
use crate::domain::paths::Paths;
use crate::domain::project::settings_path;
use crate::domain::registry::project_dir;
pub(crate) const TODO_FILE: &str = "OFFENE-PUNKTE.md";
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
/// Der Hook landet in der `settings.json` des Projekts und wird von Claude Code
/// bei jedem Sessionstart über die Shell ausgeführt. Der Pfad muss darum
/// gequotet werden: Er stammt aus dem Ordnernamen, den der Nutzer im Dialog
/// wählt — bei einem geklonten Fremd-Repo also von außen. Unquotiert genügte
/// ein Ordner `repo$(…)` für dauerhafte Codeausführung, und schon ein
/// Leerzeichen im Pfad hätte den Hook still zerbrochen.
fn todo_hook_command(dir: &std::path::Path) -> String {
format!(
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
shell_quote(&dir.join(TODO_FILE).to_string_lossy())
)
}
/// Ein Argument für `sh -c` in einfache Anführungszeichen setzen. Innerhalb
/// davon ist jedes Zeichen literal; einzig das Apostroph selbst muss die
/// Quotierung verlassen und wieder betreten (`'\''`).
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn hook_is_todo(group: &serde_json::Value) -> bool {
group["hooks"]
.as_array()
.map(|hs| {
hs.iter().any(|h| {
h["command"]
.as_str()
.map_or(false, |c| c.contains(TODO_FILE))
})
})
.unwrap_or(false)
}
pub(crate) fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
check_name(name)?;
let sp = settings_path(&project_dir(paths, name)?);
if !sp.is_file() {
return Ok(false);
}
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
let v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
Ok(
v["hooks"]["SessionStart"]
.as_array()
.map(|a| a.iter().any(hook_is_todo))
.unwrap_or(false),
)
}
pub(crate) fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
check_name(name)?;
let dir = project_dir(paths, name)?;
let sp = settings_path(&dir);
if !sp.is_file() {
return Err(format!("settings.json fehlt: {}", sp.display()));
}
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
let mut v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
let hooks = root
.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or("hooks ist kein Objekt")?;
let session_start = hooks
.entry("SessionStart")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("SessionStart ist kein Array")?;
session_start.retain(|g| !hook_is_todo(g));
if enabled {
session_start.insert(
0,
serde_json::json!({
"hooks": [
{ "type": "command", "command": todo_hook_command(&dir) }
]
}),
);
let todo_path = dir.join(TODO_FILE);
if !todo_path.is_file() {
fs::write(&todo_path, TODO_SKELETON)
.map_err(|e| format!("{}: {e}", todo_path.display()))?;
}
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::project::{create_project_full_in, TerminalConfig};
use crate::domain::testutil::{create_project, tmp_paths};
/// Der Hook-Befehl geht durch die Shell; ein Pfad mit Metazeichen darf dort
/// nichts ausführen. Ordnernamen sind bei geklonten Repos Fremdeingabe.
#[test]
fn hook_befehl_quotet_den_pfad() {
let cmd = todo_hook_command(std::path::Path::new("/tmp/repo$(touch /tmp/pwned)"));
assert!(cmd.ends_with("'/tmp/repo$(touch /tmp/pwned)/OFFENE-PUNKTE.md'"), "{cmd}");
// Ein Apostroph im Pfad darf die Quotierung nicht aufbrechen.
let cmd = todo_hook_command(std::path::Path::new("/tmp/o'brien"));
assert!(cmd.ends_with(r"'/tmp/o'\''brien/OFFENE-PUNKTE.md'"), "{cmd}");
// Nach dem Zerlegen an den Quotes bleibt kein unquotierter Bereich übrig,
// in dem eine Shell noch etwas zu interpretieren hätte.
assert!(!cmd.contains("$("));
}
#[test]
fn todo_zuschalten_und_abschalten() {
let p = tmp_paths();
let id =
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
assert!(!todo_state_in(&p, &id).unwrap());
set_todo_in(&p, &id, true).unwrap();
assert!(todo_state_in(&p, &id).unwrap());
let todo_path = p.projects_dir().join("proj").join(TODO_FILE);
assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON);
// doppelt aktivieren erzeugt keinen zweiten Hook
set_todo_in(&p, &id, true).unwrap();
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(),
)
.unwrap();
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(groups.iter().filter(|g| hook_is_todo(g)).count(), 1);
// Abschalten: Hook weg, Datei (mit Inhalt) bleibt
fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap();
set_todo_in(&p, &id, false).unwrap();
assert!(!todo_state_in(&p, &id).unwrap());
assert!(todo_path.is_file());
assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig"));
}
#[test]
fn todo_ohne_settings_scheitert() {
let p = tmp_paths();
create_project(&p, "alt").unwrap();
assert!(set_todo_in(&p, "alt", true).is_err());
assert!(!todo_state_in(&p, "alt").unwrap());
}
}
+555
View File
@@ -0,0 +1,555 @@
//! Trilium-Integration: das Archiv eines Projekts ist eine eigene, von
//! ai-control verwaltete Trilium-Instanz — Archivieren und Volltextsuche
//! laufen über die dokumentierte ETAPI, die Oberfläche kommt aus dem lokalen
//! Server-Prozess und wird in einem eigenen Tauri-Fenster angezeigt.
//!
//! Verwaltung komplett bei uns: versionsgepinnter Runtime-Download (Checksum),
//! ein Server-Prozess pro Projekt mit eigenem Datenverzeichnis und
//! deterministischem Port (nur 127.0.0.1) — parallele Projekte und ein
//! privates Trilium des Nutzers kollidieren nicht. Es werden ausschließlich
//! dokumentierte Schnittstellen benutzt: TRILIUM_DATA_DIR, TRILIUM_PORT,
//! ETAPI (Token über /etapi/auth/login).
//!
//! Erst-nach-Erfolg-Muster: Config-Einträge (Modul-Flag) schreibt der Wizard
//! erst, wenn Setup und Token-Login durch sind; Datei-Arbeit rollt bei
//! Fehlern zurück.
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use serde_json::{json, Value};
use crate::domain::paths::Paths;
/// Gepinntes Release; Update = Konstanten anheben (der Update-Check beim
/// App-Start vergleicht gegen diese Version, nicht gegen GitHub).
pub(crate) const VERSION: &str = "0.104.0";
/// Download-URL und SHA-256 des Server-Builds je Plattform. macOS/Windows
/// folgen, sobald unsere Pipeline eigene Server-Bundles baut (offizielle
/// Server-Builds gibt es nur für Linux).
#[cfg(target_os = "linux")]
const ASSET: (&str, &str) = (
"https://github.com/TriliumNext/Trilium/releases/download/v0.104.0/TriliumNotes-Server-v0.104.0-linux-x64.tar.xz",
"96b64bd1cf628ef8d851094de813750611d1d8a69c146ea1e8acc896420a2c69",
);
#[cfg(not(target_os = "linux"))]
const ASSET: (&str, &str) = ("", "");
/// Wurzel der Trilium-Verwaltung: Runtime und Projekt-Datenverzeichnisse.
pub(crate) fn trilium_root(paths: &Paths) -> PathBuf {
paths.config_dir().join("trilium")
}
/// Entpackte Runtime der gepinnten Version.
pub(crate) fn runtime_dir(paths: &Paths) -> PathBuf {
trilium_root(paths).join(format!("runtime-{VERSION}"))
}
fn runtime_binary(paths: &Paths) -> PathBuf {
runtime_dir(paths).join("node").join("bin").join("node")
}
fn runtime_main(paths: &Paths) -> PathBuf {
runtime_dir(paths).join("main.cjs")
}
/// Datenverzeichnis der Projekt-Instanz (TRILIUM_DATA_DIR) — maschinenlokal,
/// unter der Projekt-UUID.
pub(crate) fn data_dir(paths: &Paths, project: &str) -> PathBuf {
// Zweite Linie wie bei den Panel-Kanälen: eine Projekt-ID mit
// Pfad-Bestandteilen bricht laut ab, statt einen Pfad zu bilden.
crate::domain::check_name(project).unwrap();
trilium_root(paths).join("data").join(project)
}
/// Deterministischer Port pro Projekt (FNV-1a über die Projekt-ID) im
/// Bereich 2720027981 — dasselbe Schema wie die übrigen Projekt-Ports.
pub(crate) fn port_for(project: &str) -> u16 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in project.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
27200 + (h % 782) as u16
}
pub(crate) fn base_url(project: &str) -> String {
format!("http://127.0.0.1:{}", port_for(project))
}
/// Lädt und entpackt die gepinnte Runtime, falls sie fehlt. Download in eine
/// Temp-Datei, SHA-256-Prüfung, Entpacken neben das Ziel, atomares Umbenennen
/// — ein Abbruch lässt keinen halben Runtime-Ordner zurück.
pub(crate) fn ensure_runtime(paths: &Paths) -> Result<(), String> {
if runtime_binary(paths).is_file() {
return Ok(());
}
let (url, sha) = ASSET;
if url.is_empty() {
return Err("Trilium-Runtime für dieses Betriebssystem noch nicht verfügbar".into());
}
let root = trilium_root(paths);
fs::create_dir_all(&root).map_err(|e| format!("{}: {e}", root.display()))?;
let tarball = root.join(format!("download-{VERSION}.tar.xz"));
let status = Command::new("curl")
.args(["-fsSL", "--retry", "2", "-o"])
.arg(&tarball)
.arg(url)
.status()
.map_err(|e| format!("curl: {e}"))?;
if !status.success() {
let _ = fs::remove_file(&tarball);
return Err(format!("Download fehlgeschlagen ({status}): {url}"));
}
let bytes = fs::read(&tarball).map_err(|e| format!("{}: {e}", tarball.display()))?;
let digest = {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(&bytes);
format!("{:x}", h.finalize())
};
if digest != sha {
let _ = fs::remove_file(&tarball);
return Err(format!("Checksum-Abweichung beim Trilium-Download: {digest} statt {sha}"));
}
// Ins Temp-Verzeichnis entpacken, dann den inneren Ordner atomar an den
// Zielnamen heben.
let unpack = root.join(format!("unpack-{VERSION}"));
let _ = fs::remove_dir_all(&unpack);
fs::create_dir_all(&unpack).map_err(|e| format!("{}: {e}", unpack.display()))?;
let status = Command::new("tar")
.arg("-xJf")
.arg(&tarball)
.arg("-C")
.arg(&unpack)
.arg("--strip-components=1")
.status()
.map_err(|e| format!("tar: {e}"))?;
let _ = fs::remove_file(&tarball);
if !status.success() {
let _ = fs::remove_dir_all(&unpack);
return Err(format!("Entpacken fehlgeschlagen ({status})"));
}
fs::rename(&unpack, runtime_dir(paths))
.map_err(|e| format!("{}: {e}", runtime_dir(paths).display()))
}
/// Laufende Projekt-Instanzen dieses App-Prozesses.
static INSTANCES: Mutex<Option<HashMap<String, Child>>> = Mutex::new(None);
/// Startet die Projekt-Instanz, falls sie nicht läuft, und wartet bis der
/// Port antwortet. Läuft der Port schon (z. B. Instanz aus einem früheren
/// App-Lauf), wird nichts gestartet.
pub(crate) fn ensure_instance(paths: &Paths, project: &str) -> Result<(), String> {
if http_ok(project, "/", 1) {
return Ok(());
}
ensure_runtime(paths)?;
let dd = data_dir(paths, project);
fs::create_dir_all(&dd).map_err(|e| format!("{}: {e}", dd.display()))?;
let mut guard = INSTANCES.lock().unwrap();
let map = guard.get_or_insert_with(HashMap::new);
// Toter Eintrag (Prozess beendet) fliegt raus, bevor neu gestartet wird.
if let Some(child) = map.get_mut(project) {
match child.try_wait() {
Ok(None) => {}
_ => {
map.remove(project);
}
}
}
if !map.contains_key(project) {
let child = Command::new(runtime_binary(paths))
.arg(runtime_main(paths))
.current_dir(runtime_dir(paths))
.env("TRILIUM_DATA_DIR", &dd)
.env("TRILIUM_PORT", port_for(project).to_string())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Trilium-Start: {e}"))?;
map.insert(project.to_string(), child);
}
drop(guard);
if http_ok(project, "/", 20) {
Ok(())
} else {
Err(format!("Trilium antwortet nicht auf Port {}", port_for(project)))
}
}
/// Beendet die Projekt-Instanz dieses App-Prozesses (falls von uns gestartet).
pub(crate) fn stop_instance(project: &str) {
let mut guard = INSTANCES.lock().unwrap();
if let Some(map) = guard.as_mut() {
if let Some(mut child) = map.remove(project) {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// Beendet alle von uns gestarteten Instanzen (App-Ende).
pub(crate) fn stop_all() {
let mut guard = INSTANCES.lock().unwrap();
if let Some(map) = guard.as_mut() {
for (_, mut child) in map.drain() {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// Wartet bis zu `tries` Sekunden darauf, dass GET auf `path` antwortet.
fn http_ok(project: &str, path: &str, tries: u32) -> bool {
for i in 0..tries {
if i > 0 {
std::thread::sleep(std::time::Duration::from_secs(1));
}
let ok = Command::new("curl")
.args(["-fsS", "-m", "2", "-o", "/dev/null"])
.arg(format!("{}{}", base_url(project), path))
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
return true;
}
}
false
}
/// Ist die Datenbank der Instanz schon initialisiert? (Vor dem ersten Setup
/// liefert Trilium die Setup-Seite aus; der dokumentierte ETAPI-Login
/// existiert erst danach sinnvoll.) Erkennung über die App-Info der ETAPI:
/// 401 = initialisiert (Auth nötig), Verbindung da; die Setup-Seite selbst
/// beantwortet /etapi/… nicht mit 401.
pub(crate) fn etapi(
project: &str,
method: &str,
path: &str,
token: Option<&str>,
body: Option<&Value>,
) -> Result<(u16, Value), String> {
let mut cmd = Command::new("curl");
cmd.args(["-sS", "-m", "10", "-X", method, "-w", "\n%{http_code}"]);
if let Some(token) = token {
cmd.arg("-H").arg(format!("Authorization: {token}"));
}
if let Some(body) = body {
cmd.args(["-H", "Content-Type: application/json", "--data-binary", &body.to_string()]);
}
cmd.arg(format!("{}{}", base_url(project), path));
let out = cmd.output().map_err(|e| format!("curl: {e}"))?;
if !out.status.success() && out.stdout.is_empty() {
return Err(format!("ETAPI {path}: {}", String::from_utf8_lossy(&out.stderr)));
}
let text = String::from_utf8_lossy(&out.stdout);
let (payload, code) = text.rsplit_once('\n').ok_or_else(|| format!("ETAPI {path}: leere Antwort"))?;
let code: u16 = code.trim().parse().map_err(|_| format!("ETAPI {path}: Statuszeile fehlt"))?;
let value = if payload.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(payload).unwrap_or(Value::String(payload.to_string()))
};
Ok((code, value))
}
/// Ist die Datenbank der Instanz schon initialisiert? (GET /api/setup/status,
/// erreichbar ohne Auth.)
pub(crate) fn initialized(project: &str) -> Result<bool, String> {
let (code, value) = etapi(project, "GET", "/api/setup/status", None, None)?;
if code != 200 {
return Err(format!("Setup-Status fehlgeschlagen (HTTP {code})"));
}
Ok(value["isInitialized"].as_bool() == Some(true))
}
/// Legt das Initial-Dokument an (ohne Demo-Notizen). Die Route ist nur
/// erreichbar, solange die Instanz uninitialisiert ist, und antwortet erst
/// nach abgeschlossener Datenbank-Anlage.
pub(crate) fn setup_new_document(project: &str) -> Result<(), String> {
let code = http_post(project, "/api/setup/new-document?skipDemoDb", None, 120)?;
if code >= 300 {
return Err(format!("Dokument-Anlage fehlgeschlagen (HTTP {code})"));
}
Ok(())
}
/// Setzt das Passwort der frischen Instanz (Route nur erreichbar, solange
/// keins gesetzt ist); Trilium antwortet mit Redirect auf die Login-Seite.
pub(crate) fn set_password(project: &str, password: &str) -> Result<(), String> {
let form = format!("password1={0}&password2={0}", uri_encode(password));
let code = http_post(project, "/set-password", Some(&form), 30)?;
if code != 302 {
return Err(format!("Passwort setzen fehlgeschlagen (HTTP {code})"));
}
Ok(())
}
/// Formular-/Setup-POST außerhalb der ETAPI; liefert den Statuscode.
fn http_post(
project: &str,
path: &str,
form: Option<&str>,
timeout_s: u32,
) -> Result<u16, String> {
let mut cmd = Command::new("curl");
cmd.args(["-sS", "-m", &timeout_s.to_string(), "-o", "/dev/null", "-w", "%{http_code}", "-X", "POST"]);
if let Some(form) = form {
cmd.args(["--data", form]);
}
cmd.arg(format!("{}{}", base_url(project), path));
let out = cmd.output().map_err(|e| format!("curl: {e}"))?;
String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.map_err(|_| format!("POST {path}: {}", String::from_utf8_lossy(&out.stderr)))
}
/// Holt per dokumentiertem Login einen ETAPI-Token (Passwort kommt aus dem
/// Wizard und wird nicht gespeichert).
pub(crate) fn login(project: &str, password: &str) -> Result<String, String> {
let (code, value) = etapi(
project,
"POST",
"/etapi/auth/login",
None,
Some(&json!({ "password": password })),
)?;
if code != 201 {
return Err(format!("ETAPI-Login fehlgeschlagen (HTTP {code})"));
}
value["authToken"]
.as_str()
.map(str::to_string)
.ok_or_else(|| "ETAPI-Login ohne authToken".into())
}
/// Keyring-Ablage des ETAPI-Tokens (Service wie die übrigen ai-control-Keys).
const TOKEN_SERVICE: &str = "ai-control-trilium";
pub(crate) fn store_token(project: &str, token: &str) -> Result<(), String> {
keyring::Entry::new(TOKEN_SERVICE, project)
.and_then(|e| e.set_password(token))
.map_err(|e| format!("Keyring: {e}"))
}
pub(crate) fn read_token(project: &str) -> Result<String, String> {
keyring::Entry::new(TOKEN_SERVICE, project)
.and_then(|e| e.get_password())
.map_err(|e| format!("Keyring: {e}"))
}
pub(crate) fn delete_token(project: &str) {
if let Ok(entry) = keyring::Entry::new(TOKEN_SERVICE, project) {
let _ = entry.delete_credential();
}
}
/// Percent-Encoding für Query-Parameter (ETAPI-Suche).
pub(crate) fn uri_encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// Runtime der gepinnten Version liegt entpackt da.
pub(crate) fn runtime_ready(paths: &Paths) -> bool {
runtime_binary(paths).is_file()
}
/// Antwortet die Projekt-Instanz gerade? (Ein Versuch, ohne Warten.)
pub(crate) fn instance_running(project: &str) -> bool {
http_ok(project, "/", 1)
}
/// Prüft den gespeicherten Token gegen die Instanz (GET /etapi/app-info).
pub(crate) fn token_ok(project: &str) -> bool {
let Ok(token) = read_token(project) else { return false };
matches!(etapi(project, "GET", "/etapi/app-info", Some(&token), None), Ok((200, _)))
}
/// Findet unter `parent` das Kind mit diesem Titel oder legt es als
/// Buch-Notiz an (Triliums Ordner-Äquivalent: Notiz mit Kindern).
fn find_or_create_child(
project: &str,
token: &str,
parent: &str,
title: &str,
) -> Result<String, String> {
let (code, parent_note) =
etapi(project, "GET", &format!("/etapi/notes/{parent}"), Some(token), None)?;
if code != 200 {
return Err(format!("ETAPI: Notiz {parent} nicht lesbar (HTTP {code})"));
}
for child in parent_note["childNoteIds"].as_array().into_iter().flatten() {
let Some(id) = child.as_str() else { continue };
let (code, note) =
etapi(project, "GET", &format!("/etapi/notes/{id}"), Some(token), None)?;
if code == 200 && note["title"].as_str() == Some(title) {
return Ok(id.to_string());
}
}
let (code, created) = etapi(
project,
"POST",
"/etapi/create-note",
Some(token),
Some(&json!({ "parentNoteId": parent, "title": title, "type": "book", "content": "" })),
)?;
if code != 201 {
return Err(format!("ETAPI: Ordner-Notiz „{title}“ nicht anlegbar (HTTP {code})"));
}
created["note"]["noteId"]
.as_str()
.map(str::to_string)
.ok_or_else(|| "ETAPI: create-note ohne noteId".into())
}
/// Archiviert einen Markdown-Text als Trilium-Notiz: HTML-Konvertierung,
/// Ordner-Pfad als Notiz-Hierarchie unter root, Beschreibung und Schlagwörter
/// als Labels. Liefert die noteId.
pub(crate) fn archive_note(
project: &str,
title: &str,
markdown: &str,
folder: Option<&str>,
description: Option<&str>,
tags: &[String],
) -> Result<String, String> {
let token = read_token(project)?;
let html = {
let parser = pulldown_cmark::Parser::new_ext(markdown, pulldown_cmark::Options::all());
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, parser);
html
};
let mut parent = "root".to_string();
if let Some(folder) = folder {
for segment in folder.split('/').map(str::trim).filter(|s| !s.is_empty()) {
parent = find_or_create_child(project, &token, &parent, segment)?;
}
}
let (code, created) = etapi(
project,
"POST",
"/etapi/create-note",
Some(&token),
Some(&json!({
"parentNoteId": parent,
"title": title,
"type": "text",
"content": html,
})),
)?;
if code != 201 {
return Err(format!("ETAPI: Notiz nicht anlegbar (HTTP {code})"));
}
let note_id = created["note"]["noteId"]
.as_str()
.map(str::to_string)
.ok_or_else(|| String::from("ETAPI: create-note ohne noteId"))?;
let mut labels: Vec<(&str, String)> = Vec::new();
if let Some(d) = description {
labels.push(("description", d.to_string()));
}
for tag in tags {
labels.push(("tag", tag.clone()));
}
for (name, value) in labels {
let (code, _) = etapi(
project,
"POST",
"/etapi/attributes",
Some(&token),
Some(&json!({ "noteId": note_id, "type": "label", "name": name, "value": value })),
)?;
if code != 201 {
return Err(format!("ETAPI: Label {name} nicht setzbar (HTTP {code})"));
}
}
Ok(note_id)
}
#[derive(serde::Serialize)]
pub(crate) struct Hit {
pub(crate) note_id: String,
pub(crate) title: String,
}
/// Volltextsuche über die Projekt-Instanz; `tag` engt auf das Label
/// `#tag=<wert>` ein (so setzt archive_note Schlagwörter).
pub(crate) fn search_notes(
project: &str,
query: &str,
tag: Option<&str>,
limit: usize,
) -> Result<Vec<Hit>, String> {
let token = read_token(project)?;
let mut q = query.trim().to_string();
if let Some(tag) = tag {
q = format!("{q} #tag=\"{}\"", tag.replace('"', "")).trim().to_string();
}
let (code, value) = etapi(
project,
"GET",
&format!("/etapi/notes?search={}&limit={limit}", uri_encode(&q)),
Some(&token),
None,
)?;
if code != 200 {
return Err(format!("ETAPI-Suche fehlgeschlagen (HTTP {code})"));
}
Ok(
value["results"]
.as_array()
.into_iter()
.flatten()
.filter_map(|n| {
Some(Hit {
note_id: n["noteId"].as_str()?.to_string(),
title: n["title"].as_str().unwrap_or("(ohne Titel)").to_string(),
})
})
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn port_stabil_und_im_bereich() {
let a = port_for("projekt-a");
assert_eq!(a, port_for("projekt-a"));
assert!((27200..27982).contains(&a));
assert_ne!(a, port_for("projekt-b"));
}
#[test]
fn pfade_unter_config_dir() {
let p = crate::domain::testutil::tmp_paths();
assert!(runtime_dir(&p).starts_with(p.config_dir()));
assert!(data_dir(&p, "proj").starts_with(trilium_root(&p)));
}
}
+7 -5
View File
@@ -8,7 +8,7 @@ use std::io::{BufRead, BufReader};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::domain::paths::Paths; use crate::domain::paths::Paths;
use crate::domain::pool::{read_pool, POOL_FILE}; use crate::domain::pool::{pool_config_dir, read_pool, POOL_FILE};
use crate::domain::registry::load_registry; use crate::domain::registry::load_registry;
#[derive(Serialize, Default)] #[derive(Serialize, Default)]
@@ -155,12 +155,14 @@ pub(crate) fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>,
let pool_entry = pool_entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?; let pool_entry = pool_entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
let id = pool_entry.file_name().to_string_lossy().into_owned(); let id = pool_entry.file_name().to_string_lossy().into_owned();
// Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen. // Anzeigename aus pool.json; Ordner ohne pool.json unter der ID ausweisen.
let pool = if pool_entry.path().join(POOL_FILE).is_file() { // Die Transkripte liegen im Config-Verzeichnis des Pools — bei einem
read_pool(paths, &id)?.name // referenzierten Pool also außerhalb seiner Hülle.
let (pool, config_dir) = if pool_entry.path().join(POOL_FILE).is_file() {
(read_pool(paths, &id)?.name, pool_config_dir(paths, &id)?)
} else { } else {
id (id, pool_entry.path())
}; };
let projects_root = pool_entry.path().join("projects"); let projects_root = config_dir.join("projects");
if !projects_root.is_dir() { if !projects_root.is_dir() {
continue; continue;
} }
+22 -4
View File
@@ -51,7 +51,7 @@ pub fn run() {
if std::env::var_os("APPIMAGE").is_some() && platform::is_gnome() { if std::env::var_os("APPIMAGE").is_some() && platform::is_gnome() {
rfd::MessageDialog::new() rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Warning) .set_level(rfd::MessageLevel::Warning)
.set_title("ai-control") .set_title("aICentral")
.set_description( .set_description(
"Die AppImage-Fassung wird unter GNOME nicht unterstützt: Das Tray-Icon \ "Die AppImage-Fassung wird unter GNOME nicht unterstützt: Das Tray-Icon \
braucht eine GNOME-Shell-Extension, die nur das deb-/rpm-Paket \ braucht eine GNOME-Shell-Extension, die nur das deb-/rpm-Paket \
@@ -62,6 +62,13 @@ pub fn run() {
std::process::exit(1); std::process::exit(1);
} }
// Frisch aus dem DMG oder aus Downloads gestartet: einmal anbieten, sich
// nach ~/Applications zu holen. Nur die Haupt-App fragt — Terminal-Prozesse
// starten aus demselben Bundle, das dann längst am Ziel liegt.
if first.is_none() {
platform::offer_move_to_applications(&domain::paths::Paths::real());
}
// generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST). // generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
let context = tauri::generate_context!(); let context = tauri::generate_context!();
match first.as_deref() { match first.as_deref() {
@@ -88,12 +95,17 @@ pub fn run() {
// Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt // Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt
// überschreibt macOS es beim Anlegen des Dock-Tiles wieder. // überschreibt macOS es beim Anlegen des Dock-Tiles wieder.
.run(move |app, event| { .run(move |app, event| {
if let tauri::RunEvent::Ready = event { match event {
tauri::RunEvent::Ready => {
if let Some(icon) = icon.as_deref() { if let Some(icon) = icon.as_deref() {
platform::set_dock_icon(icon); platform::set_dock_icon(icon);
} }
platform::activate_self(app); platform::activate_self(app);
} }
// Von diesem Prozess gestartete Trilium-Instanzen enden mit ihm.
tauri::RunEvent::Exit => domain::trilium::stop_all(),
_ => {}
}
}); });
} }
_ => { _ => {
@@ -101,8 +113,14 @@ pub fn run() {
// dem offenen Fenster über ai-control.desktop das App-Icon zu. // dem offenen Fenster über ai-control.desktop das App-Icon zu.
platform::set_app_id("ai-control"); platform::set_app_id("ai-control");
app::main_builder() app::main_builder()
.run(context) .build(context)
.expect("error while running tauri application"); .expect("error while building tauri application")
.run(|_, event| {
// Von diesem Prozess gestartete Trilium-Instanzen enden mit ihm.
if let tauri::RunEvent::Exit = event {
domain::trilium::stop_all();
}
});
} }
} }
} }
+261 -148
View File
@@ -1,8 +1,9 @@
//! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server //! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server
//! startet. Er stellt genau ein Tool bereit: `write_panel(text)` schreibt den //! startet. Die Tools kommen aus der Modul-Registry (domain/modules.rs);
//! Text in die Panel-Datei aus `AI_CONTROL_PANEL` (dieselbe Env, die der //! Puffer-Kanäle laufen über die AI_CONTROL_*-Dateipfade, die der
//! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder vererbt). //! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder
//! Der bestehende Datei-Watcher im Terminal-Prozess zieht den Inhalt ins Panel. //! vererbt. Der Datei-Watcher im Terminal-Prozess zieht Änderungen als
//! Events in die Panel-Fenster.
//! //!
//! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile). //! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile).
@@ -57,20 +58,56 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
"serverInfo": { "name": "text-panel", "version": env!("CARGO_PKG_VERSION") }, "serverInfo": { "name": "text-panel", "version": env!("CARGO_PKG_VERSION") },
})) }))
} }
// Die Tool-Liste kommt aus der Modul-Registry: nur Tools der im Projekt
// aktiven Module. Eine laufende Session behält ihre Liste vom Start —
// nachträglich abgeschaltete Module fängt der Guard in call_tool ab.
"tools/list" => Some(json!({ "tools/list" => Some(json!({
"tools": [ "tools": active_modules()
{ .iter()
.flat_map(|m| m.mcp_tools)
.map(|name| tool_def(name))
.collect::<Vec<Value>>(),
})),
"tools/call" => Some(call_tool(req)),
"ping" => Some(json!({})),
_ => None,
}
}
/// Aktive Module des Projekts aus AI_CONTROL_PROJECT. Ohne lesbare
/// Projekt-Config (Terminal außerhalb ai-control) gelten die
/// Registry-Defaults — die Tools erscheinen, ihre Aufrufe scheitern dann wie
/// bisher an der fehlenden Env.
fn active_modules() -> Vec<&'static crate::domain::modules::ModuleDesc> {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
crate::domain::modules::active_in(&crate::domain::paths::Paths::real(), &project)
.unwrap_or_else(|_| {
crate::domain::modules::MODULES
.iter()
.filter(|m| m.default_enabled)
.collect()
})
}
/// MCP-Definition eines Tools. Welche davon gelistet werden, entscheidet die
/// Modul-Registry (active_modules); jeder Name aus MODULES muss hier einen
/// Arm haben.
fn tool_def(name: &str) -> Value {
match name {
"write_panel" => json!({
"name": "write_panel", "name": "write_panel",
"description": "description":
"Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \ "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \
Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \ Commit-Message, Textbaustein) als Notiz im Trilium-Archiv des \
ab, wo er mit der Maus selektierbar und über einen Button kopierbar \ Projekts an und öffnet sie im Trilium-Fenster neben dem Terminal. \
ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \ Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \ aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \
IMMER `path` statt `text` übergeben — der Server liest die Datei \ IMMER `path` statt `text` übergeben — der Server liest die Datei \
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss. \ selbst von der Platte, ohne dass ihr Inhalt generiert werden muss. \
`path` muss im Projekt-, Arbeits- oder Archiv-Ordner liegen \ `path` muss im Projekt- oder Arbeitsordner liegen (reguläre Datei, \
(reguläre Datei, max. 2 MB).", max. 2 MB). `folder`, `description` und `tags` kuratieren die \
Notiz: Ordner werden zur Notiz-Hierarchie, Beschreibung und \
Schlagwörter zu Labels.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -81,13 +118,28 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
"path": { "path": {
"type": "string", "type": "string",
"description": "description":
"Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt ins \ "Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt \
Panel geladen wird. Genau eines von beiden angeben.", die Notiz wird. Genau eines von beiden angeben.",
},
"folder": {
"type": "string",
"description":
"Optionaler Ordner-Pfad im Archiv (z. B. `konzepte/panel`). \
Wird als Notiz-Hierarchie angelegt.",
},
"description": {
"type": "string",
"description": "Einzeiler zum Inhalt (Label `description`).",
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Schlagwörter (Labels `tag`, kurze Slugs).",
} }
}, },
}, },
}, }),
{ "write_commands" => json!({
"name": "write_commands", "name": "write_commands",
"description": "description":
"Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \ "Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \
@@ -120,8 +172,8 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
}, },
"required": ["commands"], "required": ["commands"],
}, },
}, }),
{ "show_commands" => json!({
"name": "show_commands", "name": "show_commands",
"description": "description":
"Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \ "Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \
@@ -129,70 +181,67 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \ wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \
„zeige die Befehlsliste“).", „zeige die Befehlsliste“).",
"inputSchema": { "type": "object", "properties": {} }, "inputSchema": { "type": "object", "properties": {} },
}, }),
{ "write_todos" => json!({
"name": "archive_panel", "name": "write_todos",
"description": "description":
"Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \ "Hängt Aufgaben an die persistente ToDo-Liste des Projekts an; \
Markdown-Datei im Archiv-Home des Projekts. Auf Wunsch nutzen \ sie erscheinen als Kacheln im ToDo-Tab des ai-control-Panels und \
(Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \ überleben die Session. Nutzen, wenn der Nutzer etwas auf die \
`description` und `tags` mitgeben — einmalige Kuratierung im Moment \ ToDo-Liste setzen will („auf die Liste“, „als ToDo merken“). \
des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \ `due` optional als ISO-Datum YYYY-MM-DD — das Panel zeigt die \
konfiguriert, meldet das Tool das zurück; gesetzt wird es in den \ Fälligkeit als Ampel.",
Projekt-Einstellungen oder im Panel.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"folder": { "todos": {
"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", "type": "array",
"items": { "type": "string" }, "description": "Aufgaben in Listenreihenfolge.",
"description": "Schlagwörter fürs Frontmatter (kurze Slugs).", "items": {
}
},
},
},
{
"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", "type": "object",
"properties": { "properties": {
"tag": { "text": {
"type": "string", "type": "string",
"description": "Optional: Seite dieses Schlagworts statt der Übersicht.", "description": "Die Aufgabe, knapp formuliert.",
},
"note": {
"type": "string",
"description": "Optionale Kurznotiz.",
},
"due": {
"type": "string",
"description": "Optionales Fälligkeitsdatum (YYYY-MM-DD).",
} }
}, },
"required": ["text"],
}, },
}
}, },
{ "required": ["todos"],
},
}),
"show_todos" => json!({
"name": "show_todos",
"description":
"Zeigt die ToDo-Liste im ai-control-Panel (Kachel-Ansicht), ohne \
etwas hinzuzufügen. Nutzen, wenn der Nutzer die ToDos sehen will \
(„zeig die ToDos“).",
"inputSchema": { "type": "object", "properties": {} },
}),
"search_archive" => json!({
"name": "search_archive", "name": "search_archive",
"description": "description":
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \ "Volltext-Suche über das Trilium-Archiv des Projekts (Triliums \
Wörter, \"Phrasen\", Präfix*). Die Treffer erscheinen als Kacheln \ Suchsyntax: Wörter, \"Phrasen\", Operatoren). Die Treffer \
im Panel; das Tool liefert sie zusätzlich mit Pfad und Snippet \ erscheinen als Kacheln im Panel; das Tool liefert sie zusätzlich \
zurück. Nutzen, wenn der Nutzer im Archiv suchen will („such im \ mit Titel und noteId zurück. Nutzen, wenn der Nutzer im Archiv \
Archiv nach …“).", suchen will („such im Archiv nach …“).",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"query": { "query": {
"type": "string", "type": "string",
"description": "Suchanfrage (FTS5-Syntax).", "description": "Suchanfrage (Trilium-Suchsyntax).",
}, },
"tag": { "tag": {
"type": "string", "type": "string",
@@ -200,23 +249,27 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
} }
}, },
}, },
}, }),
], other => unreachable!("tool_def ohne Definition: {other}"),
})),
"tools/call" => Some(call_tool(req)),
"ping" => Some(json!({})),
_ => None,
} }
} }
fn call_tool(req: &Value) -> Value { fn call_tool(req: &Value) -> Value {
match req["params"]["name"].as_str().unwrap_or("") { let name = req["params"]["name"].as_str().unwrap_or("");
// Guard für Sessions, deren Tool-Liste älter ist als die Projekt-Config:
// Tools mittlerweile abgeschalteter Module werden abgewiesen.
if let Some(m) = crate::domain::modules::by_tool(name) {
if !active_modules().iter().any(|a| a.id == m.id) {
return err(format!("Modul „{}“ ist in diesem Projekt abgeschaltet.", m.id));
}
}
match name {
"write_panel" => call_write(req), "write_panel" => call_write(req),
"write_commands" => call_write_commands(req), "write_commands" => call_write_commands(req),
"show_commands" => call_show_commands(), "show_commands" => call_show_commands(),
"archive_panel" => call_archive(req), "write_todos" => call_write_todos(req),
"show_todos" => call_show_todos(),
"search_archive" => call_search(req), "search_archive" => call_search(req),
"show_archive" => call_show_archive(req),
other => err(format!("Unbekanntes Tool: {other}")), other => err(format!("Unbekanntes Tool: {other}")),
} }
} }
@@ -238,36 +291,75 @@ fn env_path(var: &str) -> Result<String, Value> {
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control)."))) .map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
} }
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL. /// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile —
fn write_panel_text(text: &str) -> Result<(), Value> { /// wird der Notiz-Titel von write_panel.
let path = env_path("AI_CONTROL_PANEL")?; fn first_line(text: &str) -> String {
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}"))) let mut fallback: Option<&str> = None;
for line in text.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
let h = t.trim_start_matches('#').trim();
if t.starts_with('#') && !h.is_empty() {
return h.to_string();
}
fallback.get_or_insert(t);
}
fallback.unwrap_or("entwurf").to_string()
} }
/// Legt den Entwurf als Trilium-Notiz an (ETAPI der Projekt-Instanz; Token
/// aus dem Keyring) und meldet die noteId über den noteopen-Kanal — die
/// Fenster öffnen die Notiz daraufhin im Trilium-Fenster.
fn call_write(req: &Value) -> Value { fn call_write(req: &Value) -> Value {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
let args = &req["params"]["arguments"]; let args = &req["params"]["arguments"];
// `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne // `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne
// dass das Modell den Inhalt Token für Token als `text` generieren muss. // dass das Modell den Inhalt Token für Token als `text` generieren muss.
// Der Zugriff ist auf Projekt-, Arbeits- und Archiv-Ordner begrenzt // Der Zugriff ist auf Projekt- und Arbeitsordner begrenzt
// (read_for_panel_in): das Tool ist promptfrei freigegeben und darf das // (read_for_panel_in): das Tool ist promptfrei freigegeben und darf das
// Read-Permission-Modell von Claude Code nicht umgehen. // Read-Permission-Modell von Claude Code nicht umgehen.
let (text, ok_msg) = match args["path"].as_str() { let text = match args["path"].as_str() {
Some(src) => { Some(src) => {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
let paths = crate::domain::paths::Paths::real(); let paths = crate::domain::paths::Paths::real();
match crate::domain::project::read_for_panel_in(&paths, &project, src) { match crate::domain::project::read_for_panel_in(&paths, &project, src) {
Ok(content) => (content, "Datei ins Panel geladen."), Ok(content) => content,
Err(e) => return err(format!("Datei nicht geladen: {e}")), Err(e) => return err(format!("Datei nicht geladen: {e}")),
} }
} }
None => ( None => args["text"].as_str().unwrap_or("").to_string(),
args["text"].as_str().unwrap_or("").to_string(),
"Entwurf ins Panel geschrieben.",
),
}; };
match write_panel_text(&text) { if text.trim().is_empty() {
Ok(()) => ok(ok_msg.into()), return err("Kein Inhalt — `text` oder `path` angeben.".into());
Err(e) => e, }
let title = first_line(&text);
let tags: Vec<String> = args["tags"]
.as_array()
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
let note_id = match crate::domain::trilium::archive_note(
&project,
&title,
&text,
args["folder"].as_str().filter(|f| !f.trim().is_empty()),
args["description"].as_str().filter(|d| !d.trim().is_empty()),
&tags,
) {
Ok(note_id) => note_id,
Err(e) => {
return err(format!(
"Notiz nicht angelegt: {e}. Läuft die Trilium-Instanz? (Trilium-Modul in den Projekt-Einstellungen)"
))
}
};
let noteopen = match env_path("AI_CONTROL_NOTEOPEN") {
Ok(path) => path,
Err(e) => return e,
};
match std::fs::write(&noteopen, json!({ "noteId": note_id }).to_string()) {
Ok(()) => ok(format!("Notiz {note_id} („{title}“) im Archiv angelegt.")),
Err(e) => err(format!("Notiz {note_id} angelegt, aber Fenster nicht benachrichtigt: {e}")),
} }
} }
@@ -316,11 +408,11 @@ fn call_write_commands(req: &Value) -> Value {
} }
} }
/// Öffnet die Kachel-Ansicht, ohne der History etwas hinzuzufügen: mtime der /// Öffnet eine Kachel-Ansicht, ohne der Datei etwas hinzuzufügen: mtime
/// History-Datei anfassen genügt — der Watcher im Terminal-Prozess meldet sie /// anfassen genügt — der Watcher im Terminal-Prozess meldet die Datei als
/// als `commands-update`, das Panel schaltet auf „Befehle“ um. /// Update, das Panel schaltet auf den zugehörigen Tab um.
fn call_show_commands() -> Value { fn touch_buffer(env: &str, ok_msg: &str, err_ctx: &str) -> Value {
let path = match env_path("AI_CONTROL_COMMANDS") { let path = match env_path(env) {
Ok(path) => path, Ok(path) => path,
Err(e) => return e, Err(e) => return e,
}; };
@@ -330,76 +422,99 @@ fn call_show_commands() -> Value {
.open(&path) .open(&path)
.and_then(|f| f.set_modified(std::time::SystemTime::now())); .and_then(|f| f.set_modified(std::time::SystemTime::now()));
match res { match res {
Ok(()) => ok("Befehls-History im Panel geöffnet.".into()), Ok(()) => ok(ok_msg.into()),
Err(e) => err(format!("Command-History nicht erreichbar: {e}")), Err(e) => err(format!("{err_ctx}: {e}")),
} }
} }
/// `dir` gibt es hier bewusst nicht mehr: Das Setzen des Archiv-Homes vergibt fn call_show_commands() -> Value {
/// über `add_archive_permission` dauerhafte Rechte in der settings.json des touch_buffer(
/// Projekts (additionalDirectories + Edit-Allow) — das bleibt der UI mit "AI_CONTROL_COMMANDS",
/// Nutzer-Dialog vorbehalten, nicht einem Tool-Argument des Modells. "Befehls-History im Panel geöffnet.",
fn call_archive(req: &Value) -> Value { "Command-History nicht erreichbar",
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); )
let args = &req["params"]["arguments"];
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, None, &meta) {
Ok(path) => ok(format!("Archiviert: {}", path.display())),
Err(e) => err(format!(
"Nicht archiviert: {e}. Archiv-Ordner im Panel oder in den Projekt-Einstellungen wählen."
)),
}
} }
/// Archiv-Übersicht bzw. Schlagwort-Seite generieren und in den Wiki-Puffer fn call_show_todos() -> Value {
/// schreiben — der Watcher zieht sie als Wiki-Ansicht ins Panel. touch_buffer(
fn call_show_archive(req: &Value) -> Value { "AI_CONTROL_TODOS",
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); "ToDo-Liste im Panel geöffnet.",
let home = match crate::domain::archive::require_archive_home(&project) { "ToDo-Liste nicht erreichbar",
Ok(home) => home, )
Err(e) => return err(e), }
};
let tag = req["params"]["arguments"]["tag"].as_str(); /// Prüft ein Fälligkeitsdatum: YYYY-MM-DD, Monat 0112, Tag 0131.
let page = match crate::domain::archive_index::archive_page(&home, tag) { pub(crate) fn valid_due(due: &str) -> bool {
Ok(page) => page, let b = due.as_bytes();
Err(e) => return err(e), if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
}; return false;
let path = match env_path("AI_CONTROL_WIKI") { }
let (y, m, d) = (
due[0..4].parse::<u16>(),
due[5..7].parse::<u8>(),
due[8..10].parse::<u8>(),
);
matches!((y, m, d), (Ok(_), Ok(m), Ok(d)) if (1..=12).contains(&m) && (1..=31).contains(&d))
}
/// Hängt ToDos an die persistente Liste (JSONL, ein ToDo pro Zeile); jedes
/// bekommt eine stabile ID fürs Kachel-Löschen. Der Watcher zieht den neuen
/// Stand als `todos-update` ins Panel.
fn call_write_todos(req: &Value) -> Value {
let path = match env_path("AI_CONTROL_TODOS") {
Ok(path) => path, Ok(path) => path,
Err(e) => return e, Err(e) => return e,
}; };
let json = match serde_json::to_string(&page) { let Some(todos) = req["params"]["arguments"]["todos"].as_array() else {
Ok(json) => json, return err("todos fehlt".into());
Err(e) => return err(e.to_string()),
}; };
match std::fs::write(&path, json) { let ts = std::time::SystemTime::now()
Ok(()) => ok(match tag { .duration_since(std::time::UNIX_EPOCH)
Some(t) => format!("Schlagwort-Seite #{t} im Panel."), .map(|d| d.as_secs())
None => "Archiv-Übersicht im Panel.".to_string(), .unwrap_or(0);
}), let mut out = String::new();
Err(e) => err(format!("Wiki-Datei nicht schreibbar: {e}")), for todo in todos {
let text = todo["text"].as_str().unwrap_or("").trim();
if text.is_empty() {
return err("ToDo ohne text".into());
}
let mut rec = json!({
"id": uuid::Uuid::new_v4().to_string(),
"ts": ts,
"text": text,
});
if let Some(note) = todo["note"].as_str() {
rec["note"] = json!(note);
}
if let Some(due) = todo["due"].as_str() {
if !valid_due(due) {
return err(format!("ungültiges due-Datum: {due} (erwartet YYYY-MM-DD)"));
}
rec["due"] = json!(due);
}
out.push_str(&rec.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!("{} ToDo(s) auf der Liste.", todos.len())),
Err(e) => err(format!("ToDo-Liste nicht schreibbar: {e}")),
} }
} }
/// Volltext-Suche übers Archiv: Treffer als JSON in die Suchtreffer-Datei /// Volltext-Suche übers Trilium-Archiv: Treffer als JSON in die
/// (der Watcher zieht sie als Kacheln ins Panel) und als Text zurück an claude. /// Suchtreffer-Datei (der Watcher zieht sie als Kacheln ins Panel) und als
/// Text zurück an claude.
fn call_search(req: &Value) -> Value { fn call_search(req: &Value) -> Value {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); 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 args = &req["params"]["arguments"];
let query = args["query"].as_str().unwrap_or(""); let query = args["query"].as_str().unwrap_or("");
let tag = args["tag"].as_str(); let tag = args["tag"].as_str();
let hits = match crate::domain::archive_search::search(&home, query, tag, 20) { let hits = match crate::domain::trilium::search_notes(&project, query, tag, 20) {
Ok(hits) => hits, Ok(hits) => hits,
Err(e) => return err(format!("Suche fehlgeschlagen: {e}")), Err(e) => return err(format!("Suche fehlgeschlagen: {e}")),
}; };
@@ -410,7 +525,6 @@ fn call_search(req: &Value) -> Value {
let payload = json!({ let payload = json!({
"query": query, "query": query,
"tag": tag, "tag": tag,
"home": home.display().to_string(),
"hits": hits, "hits": hits,
}); });
if let Err(e) = std::fs::write(&search_path, payload.to_string()) { if let Err(e) = std::fs::write(&search_path, payload.to_string()) {
@@ -418,12 +532,11 @@ fn call_search(req: &Value) -> Value {
} }
let list: Vec<String> = hits let list: Vec<String> = hits
.iter() .iter()
.map(|h| format!("- {}{} ({})", h.relpath, h.title, h.snippet)) .map(|h| format!("- {} ({})", h.title, h.note_id))
.collect(); .collect();
ok(format!( ok(format!(
"{} Treffer, als Kacheln im Panel. Archiv: {}\n{}", "{} Treffer, als Kacheln im Panel.\n{}",
hits.len(), hits.len(),
home.display(),
list.join("\n") list.join("\n")
)) ))
} }
+37
View File
@@ -0,0 +1,37 @@
//! Cinnamon-Extension pro Benutzer aktivieren — analog zu
//! `gnome-extensions enable`. Systemweit liegt sie aus dem Paket; Cinnamon
//! lädt sie sofort, sobald die UUID in enabled-extensions auftaucht.
use std::process::Command;
const UUID: &str = "ai-control-popup@local";
pub(super) fn enable_extension() {
let Ok(out) = Command::new("gsettings")
.args(["get", "org.cinnamon", "enabled-extensions"])
.output()
else {
return;
};
let list = String::from_utf8_lossy(&out.stdout);
if list.contains(UUID) {
return;
}
// GVariant-Liste erweitern: "['a']" → "['a', 'ai-control-popup@local']",
// leer ("[]" bzw. typannotiert "@as []") → "['ai-control-popup@local']".
let inner = list
.trim()
.trim_start_matches("@as")
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.trim();
let new = if inner.is_empty() {
format!("['{UUID}']")
} else {
format!("[{inner}, '{UUID}']")
};
let _ = Command::new("gsettings")
.args(["set", "org.cinnamon", "enabled-extensions", &new])
.status();
}
+14 -4
View File
@@ -2,6 +2,7 @@
//! Der Klick-Weg unterscheidet sich je Desktop (GNOME-Extension vs. //! Der Klick-Weg unterscheidet sich je Desktop (GNOME-Extension vs.
//! StatusNotifierItem), das Ergebnis ist überall dasselbe Popup-Fenster. //! StatusNotifierItem), das Ergebnis ist überall dasselbe Popup-Fenster.
mod cinnamon;
mod desktop; mod desktop;
mod gnome; mod gnome;
mod kwin; mod kwin;
@@ -43,10 +44,8 @@ pub(crate) fn set_app_id(name: &str) {
glib::set_prgname(Some(name)); glib::set_prgname(Some(name));
} }
pub(crate) fn reveal_path(path: &Path) { /// Selbstinstallation gibt es nur unter macOS — hier installieren deb/rpm.
let target = path.parent().unwrap_or(path); pub(crate) fn offer_move_to_applications(_paths: &crate::domain::paths::Paths) {}
let _ = Command::new("xdg-open").arg(target).spawn();
}
// ---------- Desktop-Erkennung ---------- // ---------- Desktop-Erkennung ----------
@@ -64,6 +63,13 @@ fn is_kde() -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Cinnamon meldet sich je nach Session als „X-Cinnamon" oder „Cinnamon".
fn is_cinnamon() -> bool {
std::env::var("XDG_CURRENT_DESKTOP")
.map(|d| d.to_uppercase().contains("CINNAMON"))
.unwrap_or(false)
}
// ---------- Secrets ---------- // ---------- Secrets ----------
/// apiKeyHelper-Kommando eines apikey-Pools: liest den Key aus dem Keyring /// apiKeyHelper-Kommando eines apikey-Pools: liest den Key aus dem Keyring
@@ -105,6 +111,10 @@ pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(),
// KDE-Wayland: das Popup-Positionieren übernimmt das KWin-Script. // KDE-Wayland: das Popup-Positionieren übernimmt das KWin-Script.
if is_kde() { if is_kde() {
kwin::enable_script(); kwin::enable_script();
} else if is_cinnamon() {
// Wayland-Platzierung + X11-Fokus kommen aus der Cinnamon-Extension;
// sie liegt systemweit aus dem Paket, aktiviert wird pro Benutzer.
cinnamon::enable_extension();
} }
} }
Ok(()) Ok(())
+5 -5
View File
@@ -1,7 +1,7 @@
//! Eigenes StatusNotifierItem (ksni) statt Tauris libappindicator-Tray: //! Eigenes StatusNotifierItem (ksni) statt Tauris libappindicator-Tray:
//! libappindicator kann nur ein Menü, wir wollen Linksklick → Popup-Fenster. //! libappindicator kann nur ein Menü, wir wollen Linksklick → Popup-Fenster.
//! Activate liefert keine brauchbaren Koordinaten — der Zeiger sitzt beim //! Die Activate-Koordinaten gehen als Anchor::Click durch; ob sie brauchbar
//! Klick auf dem Icon, daher Anchor::Cursor. //! sind, hängt vom SNI-Host ab (Cinnamon ja, KDE 0,0).
use crate::platform::{Anchor, TrayCallbacks}; use crate::platform::{Anchor, TrayCallbacks};
@@ -16,13 +16,13 @@ impl ksni::Tray for AiControlTray {
"ai-control".into() "ai-control".into()
} }
fn title(&self) -> String { fn title(&self) -> String {
"ai-control".into() "aICentral".into()
} }
fn icon_pixmap(&self) -> Vec<ksni::Icon> { fn icon_pixmap(&self) -> Vec<ksni::Icon> {
self.icon.clone() self.icon.clone()
} }
fn activate(&mut self, _x: i32, _y: i32) { fn activate(&mut self, x: i32, y: i32) {
(self.cb.show)(&self.app, Anchor::Cursor); (self.cb.show)(&self.app, Anchor::Click { x, y });
} }
fn menu(&self) -> Vec<ksni::MenuItem<Self>> { fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::StandardItem; use ksni::menu::StandardItem;
+142 -8
View File
@@ -1,8 +1,9 @@
//! macOS: Aktivierung/Fokus über AppKit (objc2), Dock-Icons pro //! macOS: Aktivierung/Fokus über AppKit (objc2), Dock-Icons pro
//! Terminal-Prozess, Keychain über das security-CLI, nativer Tray. //! Terminal-Prozess, Keychain über das security-CLI, nativer Tray.
use std::path::Path; use std::io::Write;
use std::process::Command; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE}; use crate::domain::credentials::{ApikeyStore, APIKEY_SERVICE};
use crate::domain::paths::Paths; use crate::domain::paths::Paths;
@@ -77,8 +78,122 @@ pub(crate) fn focus_terminal(pid: u32) {
/// Wayland-app_id gibt es nur unter Linux. /// Wayland-app_id gibt es nur unter Linux.
pub(crate) fn set_app_id(_name: &str) {} pub(crate) fn set_app_id(_name: &str) {}
pub(crate) fn reveal_path(path: &Path) { // ---------- Selbstinstallation ----------
let _ = Command::new("open").arg("-R").arg(path).spawn();
/// App-Bundle zum Programmpfad: …/ai-control.app/Contents/MacOS/ai-control.
/// None, wenn das Binary nicht aus einem .app läuft (cargo run, CLI-Rollen).
fn bundle_dir(exe: &Path) -> Option<&Path> {
let dir = exe.parent()?.parent()?.parent()?;
(dir.extension()? == "app").then_some(dir)
}
/// Wohin die App sich holen würde: nach ~/Applications, sofern sie noch in
/// keinem Programme-Ordner liegt und dort nichts gleichnamiges steht. Ein
/// Bundle im DMG (`/Volumes/…`) oder in Downloads fällt darunter.
fn move_target(exe: &Path, home: &Path) -> Option<(PathBuf, PathBuf)> {
let bundle = bundle_dir(exe)?;
let parent = bundle.parent()?;
let user_apps = home.join("Applications");
if parent == user_apps || parent == Path::new("/Applications") {
return None;
}
let target = user_apps.join(bundle.file_name()?);
(!target.exists()).then(|| (bundle.to_path_buf(), target))
}
/// Einmalig anbieten, sich nach ~/Applications zu holen — der Weg dorthin
/// braucht kein Administrator-Passwort, anders als /Applications. Läuft die
/// App schon aus einem Programme-Ordner oder hat der Nutzer abgelehnt,
/// passiert nichts. Im Debug-Build nie (dort liegt das Bundle im target/).
pub(crate) fn offer_move_to_applications(paths: &Paths) {
if cfg!(debug_assertions) || crate::domain::settings::move_offer_dismissed(paths) {
return;
}
let Ok(exe) = std::env::current_exe() else { return };
let Some((bundle, target)) = move_target(&exe, &paths.home) else {
return;
};
let deutsch = std::env::var("LANG").unwrap_or_default().starts_with("de");
let (title, body, yes, no) = if deutsch {
(
"aICentral installieren?",
"Die App läuft noch nicht aus einem Programme-Ordner. Soll sie sich nach „Applications\" im Benutzerordner holen und von dort neu starten?",
"Installieren",
"Später",
)
} else {
(
"Install aICentral?",
"The app is not running from an applications folder yet. Move it to \"Applications\" in your home folder and restart from there?",
"Install",
"Not now",
)
};
let antwort = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Info)
.set_title(title)
.set_description(body)
.set_buttons(rfd::MessageButtons::OkCancelCustom(yes.into(), no.into()))
.show();
if antwort != rfd::MessageDialogResult::Custom(yes.into()) {
let _ = crate::domain::settings::set_move_offer_dismissed_in(paths, true);
return;
}
// ditto statt fs::copy: überträgt Rechte und erweiterte Attribute des
// Bundles am Stück. Scheitert es, bleibt alles wie es war.
if std::fs::create_dir_all(target.parent().unwrap()).is_err()
|| !Command::new("/usr/bin/ditto")
.args([&bundle, &target])
.status()
.is_ok_and(|s| s.success())
{
return;
}
// Die Kopie erbt die Quarantäne des Downloads; ohne sie zu lösen käme beim
// Neustart erneut die Gatekeeper-Warnung für dieselbe, gerade laufende App.
let _ = Command::new("/usr/bin/xattr")
.args(["-dr", "com.apple.quarantine"])
.arg(&target)
.status();
let _ = Command::new(LSREGISTER).arg("-f").arg(&target).status();
let _ = Command::new("open").arg("-n").arg(&target).status();
std::process::exit(0);
}
const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
#[cfg(test)]
mod install_tests {
use super::*;
fn exe_in(dir: &str) -> PathBuf {
Path::new(dir).join("ai-control.app/Contents/MacOS/ai-control")
}
#[test]
fn angebot_aus_dmg_und_downloads() {
let home = Path::new("/Users/t");
let (bundle, target) = move_target(&exe_in("/Volumes/ai-control"), home).unwrap();
assert_eq!(bundle, Path::new("/Volumes/ai-control/ai-control.app"));
assert_eq!(target, Path::new("/Users/t/Applications/ai-control.app"));
assert!(move_target(&exe_in("/Users/t/Downloads"), home).is_some());
}
/// Aus einem Programme-Ordner heraus gibt es nichts anzubieten.
#[test]
fn kein_angebot_aus_programme_ordnern() {
let home = Path::new("/Users/t");
assert!(move_target(&exe_in("/Users/t/Applications"), home).is_none());
assert!(move_target(&exe_in("/Applications"), home).is_none());
}
/// Ohne .app-Bundle (cargo run, MCP-Rolle) kein Angebot.
#[test]
fn kein_angebot_ohne_bundle() {
assert!(move_target(Path::new("/w/target/release/ai-control"), Path::new("/Users/t")).is_none());
}
} }
// ---------- Desktop-Integration (nur Linux substantiell) ---------- // ---------- Desktop-Integration (nur Linux substantiell) ----------
@@ -98,14 +213,32 @@ pub(crate) struct KeychainStore;
impl ApikeyStore for KeychainStore { impl ApikeyStore for KeychainStore {
fn set(&self, pool: &str, key: &str) -> Result<(), String> { fn set(&self, pool: &str, key: &str) -> Result<(), String> {
let out = Command::new("security") // -w ohne Wert: security liest das Secret über stdin, zweimal (Eingabe und
.args(["add-generic-password", "-U", "-s", APIKEY_SERVICE, "-a", pool, "-w", key]) // Bestätigung). Als Argument übergeben stünde der Key für die Dauer des
.output() // Aufrufs in der Prozessliste und wäre für jeden `ps` lesbar.
let mut child = Command::new("security")
.args(["add-generic-password", "-U", "-s", APIKEY_SERVICE, "-a", pool, "-w"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
child
.stdin
.take()
.ok_or("security: kein stdin")?
.write_all(format!("{key}\n{key}\n").as_bytes())
.map_err(|e| e.to_string())?;
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if out.status.success() { if out.status.success() {
Ok(()) Ok(())
} else { } else {
Err(String::from_utf8_lossy(&out.stderr).into_owned()) // Die Eingabeaufforderungen landen auf stderr und stehen sonst vor der
// eigentlichen Fehlermeldung.
let err = String::from_utf8_lossy(&out.stderr)
.replace("password data for new item: ", "")
.replace("retype password for new item: ", "");
Err(err.trim().to_string())
} }
} }
@@ -185,3 +318,4 @@ pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(),
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
+5 -4
View File
@@ -6,7 +6,7 @@
//! Prozesse terminal_pids, kill_terminal //! Prozesse terminal_pids, kill_terminal
//! Fokus/Aktivierung focus_terminal, activate_self, yield_activation //! Fokus/Aktivierung focus_terminal, activate_self, yield_activation
//! Fenster/Icons set_dock_icon, set_app_id //! Fenster/Icons set_dock_icon, set_app_id
//! Dateisystem home_dir, write_secret_file, symlink, reveal_path //! Dateisystem home_dir, write_secret_file, symlink
//! Shell shell_command //! Shell shell_command
//! Secrets KeychainStore (impl ApikeyStore), apikey_helper_command, //! Secrets KeychainStore (impl ApikeyStore), apikey_helper_command,
//! oauth_keychain_exists, oauth_keychain_delete //! oauth_keychain_exists, oauth_keychain_delete
@@ -55,9 +55,10 @@ pub(crate) enum Anchor {
/// entscheidet die Plattform: Menüleiste oben → Popup darunter (macOS), /// entscheidet die Plattform: Menüleiste oben → Popup darunter (macOS),
/// Taskbar unten → Popup darüber (Windows). /// Taskbar unten → Popup darüber (Windows).
IconRect { rect: tauri::Rect, popup_below: bool }, IconRect { rect: tauri::Rect, popup_below: bool },
/// SNI/ksni liefert keine Koordinaten — der Zeiger steht beim Klick auf dem /// SNI/ksni (KDE/XFCE/Cinnamon): Klick-Koordinaten aus Activate. Cinnamon
/// Icon (KDE/XFCE/Cinnamon). /// liefert echte Werte; wo der Host keine mitgibt (0,0), fällt app.rs auf
Cursor, /// die Zeigerposition zurück — der Zeiger steht beim Klick auf dem Icon.
Click { x: i32, y: i32 },
/// Positionierung übernimmt der Compositor bzw. die Shell-Extension /// Positionierung übernimmt der Compositor bzw. die Shell-Extension
/// (GNOME, KDE-Wayland/KWin). /// (GNOME, KDE-Wayland/KWin).
Managed, Managed,
+3 -5
View File
@@ -44,11 +44,8 @@ pub(crate) fn focus_terminal(_pid: u32) {}
pub(crate) fn set_app_id(_name: &str) {} pub(crate) fn set_app_id(_name: &str) {}
pub(crate) fn reveal_path(path: &Path) { /// Selbstinstallation gibt es nur unter macOS — hier installiert der NSIS-Setup.
let _ = Command::new("explorer") pub(crate) fn offer_move_to_applications(_paths: &crate::domain::paths::Paths) {}
.arg(format!("/select,{}", path.display()))
.spawn();
}
// ---------- Dateisystem / Shell ---------- // ---------- Dateisystem / Shell ----------
@@ -122,3 +119,4 @@ pub(crate) fn init_tray(app: &tauri::AppHandle, cb: TrayCallbacks) -> Result<(),
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
+233 -97
View File
@@ -5,7 +5,7 @@ use std::io::{Read, Write};
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
use crate::domain::paths::{commands_file, panel_file, search_file, wiki_file, Paths}; use crate::domain::paths::{commands_file, search_file, Paths};
use crate::domain::project::{ use crate::domain::project::{
project_config, project_pool_dir, verify_project_dir_in, ProjectConfig, project_config, project_pool_dir, verify_project_dir_in, ProjectConfig,
}; };
@@ -126,29 +126,39 @@ pub fn term_start(
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Panel-Kanal: leere Datei anlegen (definierter Startzustand für den // Puffer-Kanäle aller Module (Modul-Registry): flüchtige Puffer leeren
// Watcher) und ihren Pfad als AI_CONTROL_PANEL in die PTY geben — der Skill // (Inhalte gelten pro Session), persistente (ToDo) nur anlegen; die Pfade
// schreibt seinen Entwurf dorthin. // für Env und Watcher vormerken. Bewusst alle Module, nicht nur aktive:
let panel_path = panel_file(&project); // Die Kanäle sind billig, und ein mitten in der Session zugeschaltetes
if let Some(parent) = panel_path.parent() { // Modul findet sie vor; die Abwahl wirkt auf Tool-Liste und Tabs.
let buffers: Vec<(&'static crate::domain::modules::BufferDesc, std::path::PathBuf)> =
crate::domain::modules::MODULES
.iter()
.flat_map(|m| m.buffers)
.map(|b| (b, (b.file)(&project)))
.collect();
for (b, path) in &buffers {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
let _ = std::fs::write(&panel_path, ""); if !b.persistent || !path.is_file() {
let _ = std::fs::write(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); // Trilium-Modul an: Projekt-Instanz zur Session hochfahren (im Thread —
let _ = std::fs::write(&commands_path, ""); // der Start wartet bis zu 20 s auf den Port). Fehler landen im Dev-Log;
// die Tools melden eine fehlende Instanz ohnehin laut.
// Such-Kanal: Treffer-Datei leeren — search_archive schreibt den jeweils if crate::domain::modules::active_in(&paths, &project)?
// letzten Suchlauf hinein, der Watcher zieht ihn als Kacheln ins Panel. .iter()
let search_path = search_file(&project); .any(|m| m.id == "trilium")
let _ = std::fs::write(&search_path, ""); {
let proj = project.clone();
// Wiki-Kanal: Puffer leeren — show_archive und wiki_open schreiben die std::thread::spawn(move || {
// jeweils aktuelle Wiki-Seite (JSON) hinein. if let Err(e) = crate::domain::trilium::ensure_instance(&Paths::real(), &proj) {
let wiki_path = wiki_file(&project); log::warn!("Trilium-Start ({proj}): {e}");
let _ = std::fs::write(&wiki_path, ""); }
});
}
let mut cmd = crate::platform::shell_command(&claude_command(&paths)); let mut cmd = crate::platform::shell_command(&claude_command(&paths));
cmd.cwd(&cwd); cmd.cwd(&cwd);
@@ -158,10 +168,9 @@ pub fn term_start(
cmd.env_remove("ANTHROPIC_API_KEY"); cmd.env_remove("ANTHROPIC_API_KEY");
cmd.env_remove("ANTHROPIC_AUTH_TOKEN"); cmd.env_remove("ANTHROPIC_AUTH_TOKEN");
cmd.env("TERM", "xterm-256color"); cmd.env("TERM", "xterm-256color");
cmd.env("AI_CONTROL_PANEL", &panel_path); for (b, path) in &buffers {
cmd.env("AI_CONTROL_COMMANDS", &commands_path); cmd.env(b.env, path);
cmd.env("AI_CONTROL_SEARCH", &search_path); }
cmd.env("AI_CONTROL_WIKI", &wiki_path);
cmd.env("AI_CONTROL_PROJECT", &project); cmd.env("AI_CONTROL_PROJECT", &project);
if let Some(pool_dir) = project_pool_dir(&project)? { if let Some(pool_dir) = project_pool_dir(&project)? {
cmd.env("CLAUDE_CONFIG_DIR", pool_dir); cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
@@ -219,14 +228,9 @@ pub fn term_start(
let _ = app.emit_to(&label, "pty-exit", ()); let _ = app.emit_to(&label, "pty-exit", ());
}); });
spawn_file_watcher(window.app_handle().clone(), panel_path, "panel-update"); for (b, path) in buffers {
spawn_file_watcher( spawn_file_watcher(window.app_handle().clone(), path, b.event);
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( terminals.0.lock().unwrap().insert(
window.label().to_string(), window.label().to_string(),
@@ -257,16 +261,17 @@ fn spawn_file_watcher(app: AppHandle, path: std::path::PathBuf, event: &'static
}); });
} }
/// Aktueller Panel-Inhalt (Erstbefüllung eines gerade geöffneten Panel-Fensters). /// Aktueller Inhalt eines Modul-Puffers (Erstbefüllung einer Panel-Ansicht);
/// `buffer` ist die Puffer-ID aus der Modul-Registry. Eine noch nicht
/// geschriebene Datei liest sich als leer — wie ein leerer Puffer.
#[tauri::command] #[tauri::command]
pub fn panel_read(project: String) -> String { pub fn buffer_read(project: String, buffer: String) -> Result<String, String> {
std::fs::read_to_string(panel_file(&project)).unwrap_or_default() let b = crate::domain::modules::MODULES
} .iter()
.flat_map(|m| m.buffers)
/// Aktuelle Command-History (JSONL; Erstbefüllung der Kachel-Ansicht). .find(|b| b.id == buffer)
#[tauri::command] .ok_or_else(|| format!("unbekannter Puffer: {buffer}"))?;
pub fn commands_read(project: String) -> String { Ok(std::fs::read_to_string((b.file)(&project)).unwrap_or_default())
std::fs::read_to_string(commands_file(&project)).unwrap_or_default()
} }
/// Entfernt einen Befehl aus der Command-History (Löschen einer Kachel im /// Entfernt einen Befehl aus der Command-History (Löschen einer Kachel im
@@ -303,76 +308,160 @@ pub fn commands_delete(project: String, id: String) -> Result<(), String> {
std::fs::write(&path, out).map_err(|e| e.to_string()) std::fs::write(&path, out).map_err(|e| e.to_string())
} }
/// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die /// Entfernt ein ToDo aus der persistenten Liste (Löschen einer Kachel) über
/// Änderung als `panel-update` an alle Fenster. /// seine stabile ID, die write_todos beim Schreiben vergibt. Der Watcher
/// meldet den neuen Stand als `todos-update`.
#[tauri::command] #[tauri::command]
pub fn panel_set(project: String, text: String) -> Result<(), String> { pub fn todos_delete(project: String, id: String) -> Result<(), String> {
std::fs::write(panel_file(&project), text).map_err(|e| e.to_string()) let path = crate::domain::paths::todos_file(&project);
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let mut removed = false;
let mut out = String::new();
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let rec: serde_json::Value = serde_json::from_str(line).map_err(|e| e.to_string())?;
if rec["id"].as_str() == Some(id.as_str()) {
removed = true;
continue;
}
out.push_str(line);
out.push('\n');
}
if !removed {
return Err("ToDo bereits entfernt".into());
}
std::fs::write(&path, out).map_err(|e| e.to_string())
} }
/// Letzter Suchlauf (JSON; Erstbefüllung der Treffer-Ansicht). /// Baut die JSONL-Zeile eines ToDos — gleiche Form wie call_write_todos im
#[tauri::command] /// MCP-Server; `id` und `ts` kommen vom Aufrufer, damit das Update beide
pub fn search_read(project: String) -> String { /// erhalten kann.
std::fs::read_to_string(search_file(&project)).unwrap_or_default() fn todo_line(
id: &str,
ts: u64,
text: &str,
note: Option<&str>,
due: Option<&str>,
) -> Result<String, String> {
let text = text.trim();
if text.is_empty() {
return Err("ToDo ohne Text".into());
}
let mut rec = serde_json::json!({ "id": id, "ts": ts, "text": text });
if let Some(note) = note {
rec["note"] = serde_json::json!(note);
}
if let Some(due) = due {
if !crate::mcp::valid_due(due) {
return Err(format!("ungültiges due-Datum: {due} (erwartet YYYY-MM-DD)"));
}
rec["due"] = serde_json::json!(due);
}
Ok(rec.to_string())
} }
/// Lädt ein Archiv-Dokument in den Dokument-Puffer (Treffer-Klick in der /// Ersetzt die Zeile mit passender ID durch Text/Notiz/Fälligkeit aus dem
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf. Der Watcher /// Formular; ID und ts der Zeile bleiben, alle anderen Zeilen bleiben
/// meldet den neuen Inhalt als `panel-update`. /// wörtlich erhalten.
#[tauri::command] fn replace_todo_line(
pub fn panel_load(project: String, path: String) -> Result<(), String> { raw: &str,
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?; id: &str,
let body = crate::domain::archive::strip_frontmatter(&text); text: &str,
std::fs::write(panel_file(&project), body).map_err(|e| e.to_string()) note: Option<&str>,
due: Option<&str>,
) -> Result<String, String> {
let mut found = false;
let mut out = String::new();
for line in raw.lines().filter(|l| !l.trim().is_empty()) {
let rec: serde_json::Value = serde_json::from_str(line).map_err(|e| e.to_string())?;
if rec["id"].as_str() == Some(id) {
found = true;
let ts = rec["ts"].as_u64().ok_or("ToDo ohne ts")?;
out.push_str(&todo_line(id, ts, text, note, due)?);
} else {
out.push_str(line);
}
out.push('\n');
}
if !found {
return Err("ToDo nicht gefunden".into());
}
Ok(out)
} }
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive und /// Legt ein ToDo aus dem Panel-Formular an (Plus-Button im ToDo-Tab). Der
/// schreibt die Treffer-Datei; der Watcher zieht sie in die Ansicht (beide /// Watcher meldet den neuen Stand als `todos-update`.
/// Fenster).
#[tauri::command] #[tauri::command]
pub fn search_run(project: String, query: String, tag: Option<String>) -> Result<(), String> { pub fn todos_add(
let home = crate::domain::archive::require_archive_home(&project)?; project: String,
let hits = crate::domain::archive_search::search(&home, &query, tag.as_deref(), 20)?; text: String,
note: Option<String>,
due: Option<String>,
) -> Result<(), String> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let line = todo_line(
&uuid::Uuid::new_v4().to_string(),
ts,
&text,
note.as_deref(),
due.as_deref(),
)?;
let path = crate::domain::paths::todos_file(&project);
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| std::io::Write::write_all(&mut f, format!("{line}\n").as_bytes()))
.map_err(|e| e.to_string())
}
/// Überschreibt Text, Notiz und Fälligkeit eines ToDos (Stift auf der
/// Kachel). Der Watcher meldet den neuen Stand als `todos-update`.
#[tauri::command]
pub fn todos_update(
project: String,
id: String,
text: String,
note: Option<String>,
due: Option<String>,
) -> Result<(), String> {
let path = crate::domain::paths::todos_file(&project);
let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let out = replace_todo_line(&raw, &id, &text, note.as_deref(), due.as_deref())?;
std::fs::write(&path, out).map_err(|e| e.to_string())
}
/// Quell-Verknüpfung des Dokument-Tabs setzen: ab jetzt schreibt jeder
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive über
/// die ETAPI der Projekt-Instanz und schreibt die Treffer-Datei; der Watcher
/// zieht sie in die Ansicht (beide Fenster).
#[tauri::command]
pub async fn search_run(
project: String,
query: String,
tag: Option<String>,
) -> Result<(), String> {
let hits = crate::domain::trilium::search_notes(&project, &query, tag.as_deref(), 20)?;
let payload = serde_json::json!({ let payload = serde_json::json!({
"query": query, "query": query,
"tag": tag, "tag": tag,
"home": home.display().to_string(),
"hits": hits, "hits": hits,
}); });
std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string()) 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 /// 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. /// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus.
/// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem /// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem
/// GTK-Mainloop klemmen kann (Tauri-Vorgabe für window create in Commands). /// GTK-Mainloop klemmen kann (Tauri-Vorgabe für window create in Commands).
#[tauri::command] #[tauri::command]
pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> { pub async fn open_panel_window(
app: AppHandle,
project: String,
mode: Option<String>,
) -> Result<(), String> {
let label = format!("panel-{project}"); let label = format!("panel-{project}");
if let Some(w) = app.get_webview_window(&label) { if let Some(w) = app.get_webview_window(&label) {
let _ = w.set_focus(); let _ = w.set_focus();
@@ -386,11 +475,13 @@ pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), St
.as_deref() .as_deref()
.or(cfg.name.as_deref()) .or(cfg.name.as_deref())
.unwrap_or(project.as_str()); .unwrap_or(project.as_str());
let builder = WebviewWindowBuilder::new( // Der beim Ablösen aktive Tab geht als URL-Parameter mit — das neue
&app, // Fenster startet dort, statt in seiner Default-Ansicht.
&label, let url = match &mode {
WebviewUrl::App(format!("panel.html?project={project}").into()), Some(m) => format!("panel.html?project={project}&mode={m}"),
) None => format!("panel.html?project={project}"),
};
let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.into()))
.title(format!("{title} — Dokument")) .title(format!("{title} — Dokument"))
.inner_size(480.0, 640.0) .inner_size(480.0, 640.0)
.background_color(tauri::window::Color(r, g, b, 0xff)); .background_color(tauri::window::Color(r, g, b, 0xff));
@@ -450,3 +541,48 @@ pub fn close(window: &tauri::Window) {
let _ = s.killer.kill(); let _ = s.killer.kill();
} }
} }
#[cfg(test)]
mod tests {
use super::{replace_todo_line, todo_line};
#[test]
fn todo_zeile_baut_und_prueft() {
let line = todo_line("i1", 100, " Aufgabe ", Some("Notiz"), Some("2026-07-24")).unwrap();
let rec: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(rec["id"], "i1");
assert_eq!(rec["ts"], 100);
assert_eq!(rec["text"], "Aufgabe");
assert_eq!(rec["note"], "Notiz");
assert_eq!(rec["due"], "2026-07-24");
// Ohne note/due fehlen die Felder.
let rec: serde_json::Value =
serde_json::from_str(&todo_line("i1", 100, "x", None, None).unwrap()).unwrap();
assert!(rec.get("note").is_none());
assert!(rec.get("due").is_none());
assert!(todo_line("i1", 100, " ", None, None).is_err());
assert!(todo_line("i1", 100, "x", None, Some("24.07.2026")).is_err());
}
#[test]
fn update_ersetzt_nur_die_passende_zeile() {
let raw = concat!(
r#"{"id":"a","ts":100,"text":"alt","note":"n"}"#, "\n",
r#"{"id":"b","ts":200,"text":"bleibt"}"#, "\n",
);
let out = replace_todo_line(raw, "a", "neu", None, Some("2026-07-24")).unwrap();
let lines: Vec<serde_json::Value> =
out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
// ID und ts bleiben, note ist weg, due ist neu.
assert_eq!(lines[0]["id"], "a");
assert_eq!(lines[0]["ts"], 100);
assert_eq!(lines[0]["text"], "neu");
assert!(lines[0].get("note").is_none());
assert_eq!(lines[0]["due"], "2026-07-24");
assert_eq!(lines[1]["text"], "bleibt");
assert!(replace_todo_line(raw, "fehlt", "x", None, None).is_err());
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ai-control", "productName": "ai-control",
"version": "0.2.0", "version": "0.3.0",
"identifier": "com.aicontrol.app", "identifier": "com.aicontrol.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -13,7 +13,7 @@
"macOSPrivateApi": true, "macOSPrivateApi": true,
"windows": [], "windows": [],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost", "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost",
"devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420" "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420"
} }
}, },
@@ -33,6 +33,8 @@
"/usr/share/gnome-shell/extensions/ai-control-popup@local/extension.js": "../gnome-shell-extension/ai-control-popup@local/extension.js", "/usr/share/gnome-shell/extensions/ai-control-popup@local/extension.js": "../gnome-shell-extension/ai-control-popup@local/extension.js",
"/usr/share/gnome-shell/extensions/ai-control-popup@local/metadata.json": "../gnome-shell-extension/ai-control-popup@local/metadata.json", "/usr/share/gnome-shell/extensions/ai-control-popup@local/metadata.json": "../gnome-shell-extension/ai-control-popup@local/metadata.json",
"/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png", "/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png",
"/usr/share/cinnamon/extensions/ai-control-popup@local/extension.js": "../cinnamon-extension/ai-control-popup@local/extension.js",
"/usr/share/cinnamon/extensions/ai-control-popup@local/metadata.json": "../cinnamon-extension/ai-control-popup@local/metadata.json",
"/usr/share/kwin/scripts/ai-control-popup/metadata.json": "../kwin-script/ai-control-popup/metadata.json", "/usr/share/kwin/scripts/ai-control-popup/metadata.json": "../kwin-script/ai-control-popup/metadata.json",
"/usr/share/kwin/scripts/ai-control-popup/contents/code/main.js": "../kwin-script/ai-control-popup/contents/code/main.js" "/usr/share/kwin/scripts/ai-control-popup/contents/code/main.js": "../kwin-script/ai-control-popup/contents/code/main.js"
} }
@@ -42,6 +44,8 @@
"/usr/share/gnome-shell/extensions/ai-control-popup@local/extension.js": "../gnome-shell-extension/ai-control-popup@local/extension.js", "/usr/share/gnome-shell/extensions/ai-control-popup@local/extension.js": "../gnome-shell-extension/ai-control-popup@local/extension.js",
"/usr/share/gnome-shell/extensions/ai-control-popup@local/metadata.json": "../gnome-shell-extension/ai-control-popup@local/metadata.json", "/usr/share/gnome-shell/extensions/ai-control-popup@local/metadata.json": "../gnome-shell-extension/ai-control-popup@local/metadata.json",
"/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png", "/usr/share/gnome-shell/extensions/ai-control-popup@local/trayLinux.png": "icons/trayLinux.png",
"/usr/share/cinnamon/extensions/ai-control-popup@local/extension.js": "../cinnamon-extension/ai-control-popup@local/extension.js",
"/usr/share/cinnamon/extensions/ai-control-popup@local/metadata.json": "../cinnamon-extension/ai-control-popup@local/metadata.json",
"/usr/share/kwin/scripts/ai-control-popup/metadata.json": "../kwin-script/ai-control-popup/metadata.json", "/usr/share/kwin/scripts/ai-control-popup/metadata.json": "../kwin-script/ai-control-popup/metadata.json",
"/usr/share/kwin/scripts/ai-control-popup/contents/code/main.js": "../kwin-script/ai-control-popup/contents/code/main.js" "/usr/share/kwin/scripts/ai-control-popup/contents/code/main.js": "../kwin-script/ai-control-popup/contents/code/main.js"
} }
+1 -1
View File
@@ -51,7 +51,7 @@ async function toggleAutostart() {
<template> <template>
<header data-tauri-drag-region> <header data-tauri-drag-region>
<h1 data-tauri-drag-region>ai-control</h1> <h1 data-tauri-drag-region>aICentral</h1>
<nav> <nav>
<button :class="{ active: tab === 'projects' }" @click="tab = 'projects'"> <button :class="{ active: tab === 'projects' }" @click="tab = 'projects'">
{{ $t("app.projects") }} {{ $t("app.projects") }}
-74
View File
@@ -1,74 +0,0 @@
// Archiv-Formular: Aufklappen, Meta-Aufbereitung, Tastatur.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { initArchiveForm } from "./archive-form";
function setup() {
document.body.innerHTML = `<button id="btn">Archiv</button>`;
const onSubmit = vi.fn();
const form = initArchiveForm(document.getElementById("btn")!, onSubmit);
const root = document.querySelector<HTMLElement>(".archive-form")!;
const inputs = [...root.querySelectorAll("input")];
return { form, onSubmit, root, inputs };
}
describe("initArchiveForm", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("klappt auf und zu", () => {
const { form, root } = setup();
expect(root.hidden).toBe(true);
form.toggle();
expect(root.hidden).toBe(false);
form.toggle();
expect(root.hidden).toBe(true);
});
it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", () => {
const { form, onSubmit, root, inputs } = setup();
form.toggle();
inputs[0].value = " konzepte/panel ";
inputs[1].value = "";
inputs[2].value = " adr, infra ,, ";
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
expect(onSubmit).toHaveBeenCalledWith({
folder: "konzepte/panel",
description: undefined,
tags: ["adr", "infra"],
});
expect(root.hidden).toBe(true);
});
/// Sonst gelten Ordner und Schlagwörter des vorigen Dokuments unbemerkt für
/// das nächste — wer direkt Enter drückt, archiviert es falsch einsortiert.
it("startet nach dem Archivieren wieder leer", () => {
const { form, onSubmit, root, inputs } = setup();
form.toggle();
inputs[0].value = "konzepte/panel";
inputs[1].value = "Beschreibung A";
inputs[2].value = "panel, wiki";
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
form.toggle();
expect(inputs.map((i) => i.value)).toEqual(["", "", ""]);
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
expect(onSubmit).toHaveBeenLastCalledWith({
folder: undefined,
description: undefined,
tags: [],
});
});
it("Enter schickt ab, Escape schließt ohne Abschicken", () => {
const { form, onSubmit, root } = setup();
form.toggle();
root.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(root.hidden).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
form.toggle();
root.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(onSubmit).toHaveBeenCalledOnce();
expect(root.hidden).toBe(true);
});
});
-72
View File
@@ -1,72 +0,0 @@
/// Archiv-Formular: klappt unter dem Archiv-Button auf und fragt Ordner,
/// Beschreibung und Schlagwörter ab (alles optional); Enter oder der Button
/// archivieren, Escape schließt. Gemeinsam für das angedockte Panel und das
/// abgelöste Fenster.
export interface ArchiveFormMeta {
folder?: string;
description?: string;
tags: string[];
}
export function initArchiveForm(
anchor: HTMLElement,
onSubmit: (meta: ArchiveFormMeta) => void,
): { toggle(): void } {
function field(placeholder: string): HTMLInputElement {
const i = document.createElement("input");
i.type = "text";
i.placeholder = placeholder;
return i;
}
const form = document.createElement("div");
form.className = "archive-form";
form.hidden = true;
const folder = field("Ordner — z. B. konzepte/panel");
const desc = field("Beschreibung");
const tags = field("Schlagwörter, kommagetrennt");
const submit = document.createElement("button");
submit.className = "archive-form-submit";
submit.textContent = "Archivieren";
form.append(folder, desc, tags, submit);
document.body.append(form);
const meta = (): ArchiveFormMeta => ({
folder: folder.value.trim() || undefined,
description: desc.value.trim() || undefined,
tags: tags.value
.split(",")
.map((t) => t.trim())
.filter(Boolean),
});
// Leeren, weil das Formular zum Fenster gehört und nicht zum Dokument.
const close = () => {
form.hidden = true;
folder.value = "";
desc.value = "";
tags.value = "";
};
const open = () => {
const r = anchor.getBoundingClientRect();
form.style.top = `${r.bottom + 6}px`;
form.style.right = `${Math.max(8, window.innerWidth - r.right - 4)}px`;
form.hidden = false;
folder.focus();
};
const fire = () => {
onSubmit(meta());
close();
};
submit.addEventListener("click", fire);
form.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
fire();
} else if (e.key === "Escape") {
close();
}
});
return { toggle: () => (form.hidden ? open() : close()) };
}
+32 -33
View File
@@ -6,67 +6,66 @@ function modeSetup() {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="tabs"> <div id="tabs">
<button data-mode="commands">Befehle</button> <button data-mode="commands">Befehle</button>
<button data-mode="draft">Dokument</button> <button data-mode="todo">ToDo</button>
<button data-mode="wiki">Wiki</button>
<button data-mode="search">Suche</button> <button data-mode="search">Suche</button>
</div> </div>
<div id="title">Mein Titel</div> <div id="title"></div>
<div id="draft-el"></div>
<div id="cc" hidden></div> <div id="cc" hidden></div>
<div id="sc" hidden></div> <div id="tc" hidden></div>
<div id="wc" hidden></div>`; <div id="sc" hidden></div>`;
const el = (id: string) => document.getElementById(id)!; const el = (id: string) => document.getElementById(id)!;
const flush = vi.fn();
const mode = initPanelMode({
tabsEl: el("tabs"),
draftEls: [el("draft-el")],
commandsContent: el("cc"),
searchContent: el("sc"),
wikiContent: el("wc"),
titleEl: el("title"),
flush,
});
const tab = (m: string) => const tab = (m: string) =>
document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!; document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!;
return { mode, flush, el, tab }; const mode = initPanelMode({
tabs: [
{ mode: "commands", btn: tab("commands"), content: el("cc"), label: "Befehle" },
{ mode: "todo", btn: tab("todo"), content: el("tc"), label: "ToDo" },
{ mode: "search", btn: tab("search"), content: el("sc"), label: "Suche" },
],
titleEl: el("title"),
});
return { mode, el, tab };
} }
describe("initPanelMode", () => { describe("initPanelMode", () => {
it("startet im Dokument-Modus mit aktivem Tab", () => { it("startet ohne aktiven Tab", () => {
const { el, tab } = modeSetup(); const { mode, el } = modeSetup();
expect(el("draft-el").hidden).toBe(false); expect(mode.current()).toBe(null);
expect(el("cc").hidden).toBe(true); expect(el("cc").hidden).toBe(true);
expect(tab("draft").classList.contains("active")).toBe(true); expect(document.querySelectorAll(".active").length).toBe(0);
}); });
it("wechselt Sichtbarkeit, Titel und aktive Markierung", () => { it("wechselt Sichtbarkeit, Titel und aktive Markierung", () => {
const { mode, flush, el, tab } = modeSetup(); const { mode, el, tab } = modeSetup();
mode.to("commands"); mode.to("commands");
expect(flush).toHaveBeenCalledOnce();
expect(el("cc").hidden).toBe(false); expect(el("cc").hidden).toBe(false);
expect(el("draft-el").hidden).toBe(true); expect(el("tc").hidden).toBe(true);
expect(el("title").textContent).toBe("Befehle"); expect(el("title").textContent).toBe("Befehle");
expect(tab("commands").classList.contains("active")).toBe(true); expect(tab("commands").classList.contains("active")).toBe(true);
expect(tab("draft").classList.contains("active")).toBe(false); mode.to("todo");
mode.to("draft"); expect(el("cc").hidden).toBe(true);
expect(el("title").textContent).toBe("Mein Titel"); expect(el("tc").hidden).toBe(false);
expect(el("title").textContent).toBe("ToDo");
expect(tab("commands").classList.contains("active")).toBe(false);
}); });
it("Tab-Klick wechselt den Modus", () => { it("Tab-Klick wechselt den Modus", () => {
const { el, tab } = modeSetup(); const { el, tab } = modeSetup();
tab("wiki").click(); tab("search").click();
expect(el("wc").hidden).toBe(false); expect(el("sc").hidden).toBe(false);
expect(el("draft-el").hidden).toBe(true); expect(el("cc").hidden).toBe(true);
}); });
it("clear hebt die Auswahl auf; danach wählt to() wieder aus", () => { it("clear hebt die Auswahl auf; danach wählt to() wieder aus", () => {
const { mode, el, tab } = modeSetup(); const { mode, el, tab } = modeSetup();
mode.to("commands");
mode.clear(); mode.clear();
expect(document.querySelectorAll(".active").length).toBe(0); expect(document.querySelectorAll(".active").length).toBe(0);
expect(el("cc").hidden).toBe(true);
expect(mode.current()).toBe(null); expect(mode.current()).toBe(null);
mode.to("draft"); mode.to("commands");
expect(tab("draft").classList.contains("active")).toBe(true); expect(tab("commands").classList.contains("active")).toBe(true);
expect(el("title").textContent).toBe("Mein Titel"); expect(el("cc").hidden).toBe(false);
}); });
}); });
+54 -104
View File
@@ -6,13 +6,8 @@
/// Fremdtext und gehen nie durch innerHTML. /// Fremdtext und gehen nie durch innerHTML.
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { storedLocale, t } from "./messages";
import { copyAction, deleteAction, flash, renderTile, stripInvisibles } from "./tiles";
/// Entfernt Bidi- und Zero-Width-Steuerzeichen (U+200B200F, U+202A202E,
/// U+20602064, U+20662069, U+FEFF) aus Befehlstexten.
function stripInvisibles(s: string): string {
return s.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g, "");
}
interface CommandEntry { interface CommandEntry {
cmd: string; cmd: string;
@@ -34,49 +29,11 @@ export interface CommandsView {
function fmtTime(ts: number): string { function fmtTime(ts: number): string {
const d = new Date(ts * 1000); const d = new Date(ts * 1000);
const hm = d.toLocaleTimeString("de", { hour: "2-digit", minute: "2-digit" }); const loc = storedLocale();
const hm = d.toLocaleTimeString(loc, { hour: "2-digit", minute: "2-digit" });
return d.toDateString() === new Date().toDateString() return d.toDateString() === new Date().toDateString()
? hm ? hm
: `${d.toLocaleDateString("de")} ${hm}`; : `${d.toLocaleDateString(loc)} ${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( export function initCommandsView(
@@ -96,7 +53,7 @@ export function initCommandsView(
if (rec.session) { if (rec.session) {
const sep = document.createElement("div"); const sep = document.createElement("div");
sep.className = "cmd-sep"; sep.className = "cmd-sep";
sep.textContent = `Session · ${fmtTime(rec.ts)}`; sep.textContent = `${t("commands.session")} · ${fmtTime(rec.ts)}`;
container.append(sep); container.append(sep);
continue; continue;
} }
@@ -113,7 +70,7 @@ export function initCommandsView(
if (cmds.length > 1) { if (cmds.length > 1) {
const all = document.createElement("button"); const all = document.createElement("button");
all.className = "cmd-all"; all.className = "cmd-all";
all.textContent = "Alle kopieren"; all.textContent = t("commands.copyAll");
all.addEventListener("click", async () => { all.addEventListener("click", async () => {
await writeText(cmds.map((c) => stripInvisibles(c.cmd)).join("\n")); await writeText(cmds.map((c) => stripInvisibles(c.cmd)).join("\n"));
flash(all, "copied"); flash(all, "copied");
@@ -124,26 +81,23 @@ export function initCommandsView(
cmds.forEach((entry) => { cmds.forEach((entry) => {
// Unsichtbare Steuerzeichen (Bidi, Zero-Width) aus Anzeige UND // Unsichtbare Steuerzeichen (Bidi, Zero-Width) aus Anzeige UND
// Kopie halten — sonst sieht der Nutzer einen anderen Befehl, als // Kopie halten (stripInvisibles); der Lösch-Abgleich läuft weiter
// die Zwischenablage enthält. Der Lösch-Abgleich läuft weiter über // über den Original-String.
// den Original-String.
const visible = stripInvisibles(entry.cmd); const visible = stripInvisibles(entry.cmd);
const tile = document.createElement("div"); block.append(
tile.className = "cmd-tile"; renderTile({
const body = document.createElement("div"); cls: "cmd-tile",
body.className = "cmd-body"; bodyCls: "cmd-body",
const cmd = document.createElement("div"); parts: [
cmd.className = "cmd-text"; { cls: "cmd-text", text: visible },
cmd.textContent = visible; ...(entry.note ? [{ cls: "cmd-note", text: entry.note }] : []),
body.append(cmd); ],
if (entry.note) { actions: [
const note = document.createElement("div"); copyAction(t("commands.copyOne"), () => visible),
note.className = "cmd-note"; deleteAction(t("commands.removeOne"), () => onDelete(entry.id ?? "")),
note.textContent = entry.note; ],
body.append(note); }),
} );
tile.append(body, copyBtn(() => visible), deleteBtn(() => onDelete(entry.id ?? "")));
block.append(tile);
}); });
container.append(block); container.append(block);
} }
@@ -163,60 +117,56 @@ export function initCommandsView(
}; };
} }
export type PanelMode = "draft" | "commands" | "search" | "wiki"; export type PanelMode = string;
const LABEL: { [m in PanelMode]: string } = { /// Ein Tab im Modus-Umschalter; kommt aus der Modul-Registry (panel-wiring).
draft: "Dokument", export interface ModeTab {
commands: "Befehle", mode: PanelMode;
search: "Suche", btn: HTMLElement;
wiki: "Wiki", /// Content-Container des Tabs.
}; content: HTMLElement;
/// Titelzeilen-Text, wenn der Tab aktiv ist.
label: string;
onActivate?: () => void;
}
/// Vier Tabs Entwurf / Befehle / Suche / Wiki: blenden Entwurfs-Inhalt samt /// Modus-Umschalter über die Tabs der Registry: blendet die Ansichten
/// zugehöriger Kopf-Controls gegen die jeweilige Ansicht aus. Ein Wechsel bei /// gegeneinander aus; die Titelzeile trägt das Label des aktiven Tabs.
/// offener Inhalts-Bearbeitung speichert den Entwurf (flush) statt zu export function initPanelMode(opts: { tabs: ModeTab[]; titleEl: HTMLElement }) {
/// blockieren.
export function initPanelMode(opts: {
tabsEl: HTMLElement;
draftEls: HTMLElement[];
commandsContent: HTMLElement;
searchContent: HTMLElement;
wikiContent: HTMLElement;
titleEl: HTMLElement;
flush: () => void;
}) {
// `null` = kein Tab aktiv (Panel zugeklappt). // `null` = kein Tab aktiv (Panel zugeklappt).
let mode: PanelMode | null = "draft"; let mode: PanelMode | null = null;
let draftTitle = "";
const tabs = [...opts.tabsEl.querySelectorAll<HTMLElement>("[data-mode]")];
function apply() { function apply() {
opts.commandsContent.hidden = mode !== "commands"; for (const tab of opts.tabs) {
opts.searchContent.hidden = mode !== "search"; tab.content.hidden = mode !== tab.mode;
opts.wikiContent.hidden = mode !== "wiki"; tab.btn.classList.toggle("active", tab.mode === mode);
for (const el of opts.draftEls) el.hidden = mode !== "draft"; }
for (const t of tabs) t.classList.toggle("active", t.dataset.mode === mode); const active = opts.tabs.find((tab) => tab.mode === mode);
if (mode && mode !== "draft") opts.titleEl.textContent = LABEL[mode]; if (active) opts.titleEl.textContent = active.label;
} }
function to(m: PanelMode) { function to(m: PanelMode) {
if (m === mode) return; if (m === mode) return;
opts.flush();
if (mode === "draft") draftTitle = opts.titleEl.textContent || "Dokument";
mode = m; mode = m;
apply(); apply();
if (m === "draft") opts.titleEl.textContent = draftTitle;
} }
/// Auswahl aufheben (Panel zugeklappt) — der nächste to()-Aufruf oder /// Auswahl aufheben (Panel zugeklappt) — der nächste to()-Aufruf oder
/// Tab-Klick wählt wieder aus. /// Tab-Klick wählt wieder aus.
function clear() { function clear() {
if (mode === "draft") draftTitle = opts.titleEl.textContent || "Dokument";
mode = null; mode = null;
for (const t of tabs) t.classList.remove("active"); for (const tab of opts.tabs) {
tab.content.hidden = true;
tab.btn.classList.remove("active");
}
} }
for (const t of tabs) t.addEventListener("click", () => to(t.dataset.mode as PanelMode)); for (const tab of opts.tabs) {
tab.btn.addEventListener("click", () => {
to(tab.mode);
tab.onActivate?.();
});
}
apply(); apply();
return { to, clear, current: () => mode }; return { to, clear, current: () => mode };
} }
+25 -5
View File
@@ -12,13 +12,18 @@ interface Pool {
projects: string[]; projects: string[];
running: string[]; running: string[];
hasCredentials: boolean; hasCredentials: boolean;
/// Bei verwiesenen Pools das fremde Config-Verzeichnis, sonst nicht gesetzt.
dir?: string;
} }
const pools = ref<Pool[]>([]); const pools = ref<Pool[]>([]);
const error = ref(""); const error = ref("");
const busy = ref(false); const busy = ref(false);
type Mode = "oauth" | "apikey"; // Claudes Default-Verzeichnis, solange es existiert und kein Pool darauf zeigt.
const defaultDir = ref<string | null>(null);
type Mode = "oauth" | "apikey" | "reference";
const dialog = ref<{ mode: Mode; editing: boolean } | null>(null); const dialog = ref<{ mode: Mode; editing: boolean } | null>(null);
const dName = ref(""); const dName = ref("");
const dKey = ref(""); const dKey = ref("");
@@ -28,12 +33,13 @@ const dPool = ref("");
const dialogTitle = computed(() => { const dialogTitle = computed(() => {
if (!dialog.value) return ""; if (!dialog.value) return "";
if (dialog.value.mode === "oauth") return t("pools.newOauthPool"); if (dialog.value.mode === "oauth") return t("pools.newOauthPool");
if (dialog.value.mode === "reference") return t("pools.newReferencePool");
if (dialog.value.editing) return t("pools.editKey", { name: dName.value }); if (dialog.value.editing) return t("pools.editKey", { name: dName.value });
return t("pools.newApikeyPool"); return t("pools.newApikeyPool");
}); });
function openNew(mode: Mode) { function openNew(mode: Mode) {
dName.value = ""; dName.value = mode === "reference" ? t("pools.referenceDefaultName") : "";
dKey.value = ""; dKey.value = "";
dPool.value = ""; dPool.value = "";
error.value = ""; error.value = "";
@@ -56,6 +62,7 @@ function closeDialog() {
async function refresh() { async function refresh() {
try { try {
pools.value = await invoke<Pool[]>("list_pools"); pools.value = await invoke<Pool[]>("list_pools");
defaultDir.value = await invoke<string | null>("default_config_dir");
error.value = ""; error.value = "";
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
@@ -74,6 +81,8 @@ async function submit(allowFile = false) {
try { try {
if (d.mode === "oauth") { if (d.mode === "oauth") {
await invoke("create_oauth_pool", { name: dName.value }); await invoke("create_oauth_pool", { name: dName.value });
} else if (d.mode === "reference") {
await invoke("create_reference_pool", { name: dName.value, dir: defaultDir.value });
} else if (d.editing) { } else if (d.editing) {
await invoke("set_apikey", { pool: dPool.value, key: dKey.value, allowFile }); await invoke("set_apikey", { pool: dPool.value, key: dKey.value, allowFile });
} else { } else {
@@ -197,6 +206,9 @@ onMounted(refresh);
<button class="primary" :disabled="busy" @click="openNew('apikey')"> <button class="primary" :disabled="busy" @click="openNew('apikey')">
{{ $t("pools.newApikey") }} {{ $t("pools.newApikey") }}
</button> </button>
<button v-if="defaultDir" :disabled="busy" @click="openNew('reference')">
{{ $t("pools.newReference") }}
</button>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
@@ -221,6 +233,7 @@ onMounted(refresh);
<tr v-for="p in pools" :key="p.id"> <tr v-for="p in pools" :key="p.id">
<td class="cell-name"> <td class="cell-name">
<strong>{{ p.name }}</strong> <strong>{{ p.name }}</strong>
<small v-if="p.dir">{{ p.dir }}</small>
</td> </td>
<td> <td>
<span class="badge" :class="p.credentialType">{{ p.credentialType }}</span> <span class="badge" :class="p.credentialType">{{ p.credentialType }}</span>
@@ -240,16 +253,19 @@ onMounted(refresh);
<button :disabled="busy" @click="openRename(p)"> <button :disabled="busy" @click="openRename(p)">
{{ $t("pools.rename") }} {{ $t("pools.rename") }}
</button> </button>
<template v-if="p.credentialType === 'oauth'"> <template v-if="p.credentialType === 'oauth' && !p.dir">
<button class="w-action" :disabled="busy" @click="askRelogin(p)"> <button class="w-action" :disabled="busy" @click="askRelogin(p)">
{{ $t("pools.relogin") }} {{ $t("pools.relogin") }}
</button> </button>
</template> </template>
<template v-else> <template v-else-if="p.credentialType === 'apikey'">
<button class="w-action" :disabled="busy" @click="openEditKey(p)"> <button class="w-action" :disabled="busy" @click="openEditKey(p)">
{{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }} {{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }}
</button> </button>
</template> </template>
<!-- Verwiesener Pool: die Anmeldung gehört dem fremden
Verzeichnis, die App fasst sie nicht an. -->
<span v-else class="w-action quiet">{{ $t("pools.referenceLogin") }}</span>
<span class="tip-wrap" :class="{ 'hover-pop': p.running.length }"> <span class="tip-wrap" :class="{ 'hover-pop': p.running.length }">
<button <button
class="danger" class="danger"
@@ -296,12 +312,16 @@ onMounted(refresh);
{{ $t("pools.oauthHint") }} {{ $t("pools.oauthHint") }}
</p> </p>
<p v-if="dialog.mode === 'reference'" class="hint">
{{ $t("pools.referenceHint", { dir: defaultDir ?? "" }) }}
</p>
<div class="actions"> <div class="actions">
<button type="button" :disabled="busy" @click="closeDialog"> <button type="button" :disabled="busy" @click="closeDialog">
{{ $t("pools.cancel") }} {{ $t("pools.cancel") }}
</button> </button>
<button type="submit" class="primary" :disabled="busy"> <button type="submit" class="primary" :disabled="busy">
{{ dialog.mode === "oauth" ? $t("pools.createPool") : $t("pools.save") }} {{ dialog.mode === "apikey" ? $t("pools.save") : $t("pools.createPool") }}
</button> </button>
</div> </div>
</form> </form>
+2 -2
View File
@@ -83,8 +83,8 @@ onUnmounted(() => {
</ul> </ul>
<footer class="ft"> <footer class="ft">
<button class="fbtn" @click="openMain">Öffnen</button> <button class="fbtn" @click="openMain">{{ $t("popup.open") }}</button>
<button class="fbtn danger" @click="quit">Beenden</button> <button class="fbtn danger" @click="quit">{{ $t("popup.quit") }}</button>
</footer> </footer>
</div> </div>
</template> </template>
+313 -91
View File
@@ -10,6 +10,7 @@ interface Project {
pool: string | null; pool: string | null;
running: boolean; running: boolean;
terminal: { theme: string | null; icon: string | null; title: string | null }; terminal: { theme: string | null; icon: string | null; title: string | null };
archive: boolean;
} }
interface Pool { interface Pool {
@@ -98,6 +99,15 @@ async function openTerminal(project: Project) {
} }
} }
/// Archiv-Zugang aus der Projektliste: öffnet das Trilium-Fenster des Projekts.
async function openArchive(project: Project) {
try {
await invoke("trilium_open", { project: project.id, noteId: null });
} catch (e) {
error.value = String(e);
}
}
interface PendingRestart { interface PendingRestart {
id: string; id: string;
name: string; name: string;
@@ -157,6 +167,12 @@ function contractHome(p: string): string {
return p.replace(/^\/(?:home|Users)\/[^/]+/, "~"); return p.replace(/^\/(?:home|Users)\/[^/]+/, "~");
} }
interface ModuleState {
id: string;
core: boolean;
enabled: boolean;
}
interface TerminalSettings { interface TerminalSettings {
id: string; id: string;
name: string; name: string;
@@ -164,20 +180,18 @@ interface TerminalSettings {
theme: string; theme: string;
icon: string | null; icon: string | null;
title: string; title: string;
todo: boolean;
workDirs: string[]; workDirs: string[];
archiveHome: string | null; modules: ModuleState[];
} }
const settings = ref<TerminalSettings | null>(null); const settings = ref<TerminalSettings | null>(null);
async function openSettings(p: Project) { async function openSettings(p: Project) {
try { try {
const todo = await invoke<boolean>("todo_state", { project: p.id });
const workDirs = await invoke<string[]>("project_work_dirs", { const workDirs = await invoke<string[]>("project_work_dirs", {
project: p.id, project: p.id,
}); });
const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", { const modules = await invoke<ModuleState[]>("module_registry", {
project: p.id, project: p.id,
}); });
settings.value = { settings.value = {
@@ -187,15 +201,115 @@ async function openSettings(p: Project) {
theme: p.terminal.theme ?? "mocha", theme: p.terminal.theme ?? "mocha",
icon: p.terminal.icon, icon: p.terminal.icon,
title: p.terminal.title ?? "", title: p.terminal.title ?? "",
todo,
workDirs, workDirs,
archiveHome, modules,
}; };
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
} }
} }
// Modul-Schalter schreiben sofort in die Projekt-Config (nur Abweichungen
// vom Default); ein Fehler stellt die Checkbox zurück. Ausnahme Trilium:
// Anhaken öffnet nur den Wizard — das Modul-Flag schreibt erst der
// erfolgreiche Setup (trilium_connect im Backend); Abwählen stoppt die
// Instanz mit.
async function toggleModule(m: ModuleState) {
const s = settings.value!;
try {
if (m.id === "trilium") {
if (m.enabled) {
m.enabled = false; // folgt erst dem Setup-Erfolg
await openTriliumWizard();
} else {
await invoke("trilium_disable", { project: s.id });
}
return;
}
await invoke("set_module", { project: s.id, module: m.id, enabled: m.enabled });
} catch (e) {
m.enabled = !m.enabled;
error.value = String(e);
}
}
// Trilium-Wizard: Runtime laden, Instanz starten, per Passwort den
// ETAPI-Token holen. Reine Checks beim Öffnen; geschrieben wird erst nach
// Erfolg (Backend-Muster), das Passwort wird nicht gespeichert.
interface TriliumStatus {
version: string;
port: number;
data_dir: string;
runtime_ready: boolean;
instance_running: boolean;
token_ok: boolean;
}
interface TriliumWizard {
status: TriliumStatus | null;
password: string;
busy: boolean;
error: string | null;
}
const triWizard = ref<TriliumWizard | null>(null);
async function openTriliumWizard() {
const s = settings.value!;
triWizard.value = { status: null, password: "", busy: false, error: null };
try {
triWizard.value.status = await invoke<TriliumStatus>("trilium_status", {
project: s.id,
});
} catch (e) {
triWizard.value.error = String(e);
}
}
async function triliumSetup() {
const s = settings.value!;
const w = triWizard.value!;
w.busy = true;
w.error = null;
try {
w.status = await invoke<TriliumStatus>("trilium_setup", { project: s.id });
} catch (e) {
w.error = String(e);
}
w.busy = false;
}
async function triliumConnect() {
const s = settings.value!;
const w = triWizard.value!;
w.busy = true;
w.error = null;
try {
w.status = await invoke<TriliumStatus>("trilium_connect", {
project: s.id,
password: w.password,
});
w.password = "";
// Modul-Flag hat das Backend gesetzt — Checkbox nachziehen.
s.modules = await invoke<ModuleState[]>("module_registry", { project: s.id });
} catch (e) {
w.error = String(e);
}
w.busy = false;
}
async function triliumOpen() {
const s = settings.value!;
const w = triWizard.value!;
w.error = null;
try {
await invoke("trilium_open", { project: s.id, noteId: null });
w.status = await invoke<TriliumStatus>("trilium_status", { project: s.id });
} catch (e) {
w.error = String(e);
}
}
// Projektordner neu zuordnen (Root, in dem die Session startet); verschoben // Projektordner neu zuordnen (Root, in dem die Session startet); verschoben
// wird nichts — der gewählte Ordner ist der neue Ort des Projekts. // wird nichts — der gewählte Ordner ist der neue Ort des Projekts.
async function changeProjectDir() { async function changeProjectDir() {
@@ -236,33 +350,6 @@ async function removeWorkDir(dir: string) {
} }
} }
// Archiv-Home schreibt wie die Arbeitsordner direkt (Config + Permissions);
// ohne Archiv zeigt das Panel nur Befehle und Dokument. Greift ab dem
// nächsten Session-Start.
async function chooseArchive() {
const s = settings.value!;
const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return;
try {
await invoke("set_archive_home_cmd", { project: s.id, dir });
s.archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
project: s.id,
});
} catch (e) {
error.value = String(e);
}
}
async function clearArchive() {
const s = settings.value!;
try {
await invoke("clear_archive_home_cmd", { project: s.id });
s.archiveHome = null;
} catch (e) {
error.value = String(e);
}
}
async function pickIcon() { async function pickIcon() {
const file = await open({ const file = await open({
multiple: false, multiple: false,
@@ -282,7 +369,6 @@ async function saveSettings() {
icon: s.icon, icon: s.icon,
title: title === "" ? null : title, title: title === "" ? null : title,
}); });
await invoke("set_todo", { project: s.id, enabled: s.todo });
settings.value = null; settings.value = null;
await refresh(); await refresh();
} catch (e) { } catch (e) {
@@ -299,7 +385,6 @@ interface Wizard {
workDir: string | null; workDir: string | null;
title: string; title: string;
theme: string; theme: string;
todo: boolean;
} }
const wizard = ref<Wizard | null>(null); const wizard = ref<Wizard | null>(null);
@@ -315,7 +400,6 @@ function openWizard() {
workDir: null, workDir: null,
title: "", title: "",
theme: "mocha", theme: "mocha",
todo: false,
}; };
} }
@@ -370,7 +454,6 @@ async function createProject() {
icon: null, icon: null,
title: title === "" ? null : title, title: title === "" ? null : title,
}, },
todo: w.todo,
}); });
wizard.value = null; wizard.value = null;
await refresh(); await refresh();
@@ -383,11 +466,8 @@ interface DeletePreview {
name: string; name: string;
projectDir: string; projectDir: string;
aiControlDir: boolean; aiControlDir: boolean;
archivePermission: boolean;
todoHook: boolean;
panelFiles: number; panelFiles: number;
archiveHome: string | null; triliumData: string | null;
archiveDocs: number;
workDirs: string[]; workDirs: string[];
} }
@@ -486,6 +566,14 @@ onUnmounted(() => window.clearInterval(timer));
</td> </td>
<td class="cell-actions"> <td class="cell-actions">
<span class="row-actions"> <span class="row-actions">
<button
v-if="p.archive"
class="gear"
:title="$t('projects.openArchive')"
@click="openArchive(p)"
>
🗃
</button>
<button <button
class="gear" class="gear"
:title="$t('projects.terminalSettings')" :title="$t('projects.terminalSettings')"
@@ -593,13 +681,6 @@ onUnmounted(() => window.clearInterval(timer));
</option> </option>
</select> </select>
</label> </label>
<label class="field">
{{ $t("projects.todo") }}
<span class="checkline">
<input v-model="wizard.todo" type="checkbox" />
{{ $t("projects.todoDesc") }}
</span>
</label>
<p class="hint">{{ $t("projects.wizardHint") }}</p> <p class="hint">{{ $t("projects.wizardHint") }}</p>
<div class="actions"> <div class="actions">
<button type="button" @click="wizard = null"> <button type="button" @click="wizard = null">
@@ -631,20 +712,13 @@ onUnmounted(() => window.clearInterval(timer));
<small>{{ $t("projects.scopeIntegrationDesc") }}</small> <small>{{ $t("projects.scopeIntegrationDesc") }}</small>
</span> </span>
</label> </label>
<label class="scope"> <label v-if="pendingDelete.preview.triliumData" class="scope">
<input <input v-model="pendingDelete.scope" type="radio" value="archive" />
v-model="pendingDelete.scope"
type="radio"
value="archive"
:disabled="!pendingDelete.preview.archiveHome"
/>
<span> <span>
<strong>{{ $t("projects.scopeArchive") }}</strong> <strong>{{ $t("projects.scopeArchive") }}</strong>
<small v-if="pendingDelete.preview.archiveHome"> <small>
{{ $t("projects.scopeArchiveDesc", { path: pendingDelete.preview.archiveHome }) }} {{ $t("projects.scopeArchiveDesc", { path: pendingDelete.preview.triliumData }) }}
({{ $t("projects.docCount", pendingDelete.preview.archiveDocs) }})
</small> </small>
<small v-else>{{ $t("projects.archiveNone") }}</small>
</span> </span>
</label> </label>
<label class="scope"> <label class="scope">
@@ -666,15 +740,12 @@ onUnmounted(() => window.clearInterval(timer));
<ul class="affected"> <ul class="affected">
<li>{{ $t("projects.artRegistry") }}</li> <li>{{ $t("projects.artRegistry") }}</li>
<li v-if="pendingDelete.preview.aiControlDir">{{ $t("projects.artAiControl") }}</li> <li v-if="pendingDelete.preview.aiControlDir">{{ $t("projects.artAiControl") }}</li>
<li v-if="pendingDelete.preview.archivePermission">{{ $t("projects.artArchivePerm") }}</li>
<li v-if="pendingDelete.preview.todoHook">{{ $t("projects.artTodoHook") }}</li>
<li v-if="pendingDelete.preview.panelFiles"> <li v-if="pendingDelete.preview.panelFiles">
{{ $t("projects.artPanelFiles", pendingDelete.preview.panelFiles) }} {{ $t("projects.artPanelFiles", pendingDelete.preview.panelFiles) }}
</li> </li>
<li>{{ $t("projects.artDesktop") }}</li> <li>{{ $t("projects.artDesktop") }}</li>
<li v-if="pendingDelete.scope !== 'integration' && pendingDelete.preview.archiveHome"> <li v-if="pendingDelete.scope !== 'integration' && pendingDelete.preview.triliumData">
{{ $t("projects.artArchive", { path: pendingDelete.preview.archiveHome }) }} {{ $t("projects.artArchive", { path: pendingDelete.preview.triliumData }) }}
({{ $t("projects.docCount", pendingDelete.preview.archiveDocs) }})
</li> </li>
<li v-if="pendingDelete.scope === 'full'"> <li v-if="pendingDelete.scope === 'full'">
{{ $t("projects.artProjectDir", { path: pendingDelete.preview.projectDir }) }} {{ $t("projects.artProjectDir", { path: pendingDelete.preview.projectDir }) }}
@@ -775,32 +846,27 @@ onUnmounted(() => window.clearInterval(timer));
<button @click="addWorkDir">{{ $t("projects.addWorkDir") }}</button> <button @click="addWorkDir">{{ $t("projects.addWorkDir") }}</button>
</span> </span>
</div> </div>
<div class="srow">
<span class="slbl">{{ $t("projects.archive") }}</span>
<span class="sval">
<span v-if="settings.archiveHome" class="spath hover-pop">
{{ contractHome(settings.archiveHome) }}
<span class="pop pop-path">{{ settings.archiveHome }}</span>
</span>
<span v-else class="spath muted">{{ $t("projects.archiveNone") }}</span>
</span>
<span class="sacts">
<button @click="chooseArchive">{{ $t("projects.changeDir") }}</button>
<button v-if="settings.archiveHome" @click="clearArchive">
{{ $t("projects.remove") }}
</button>
</span>
</div>
<p class="ghint">{{ $t("projects.writesImmediately") }}</p> <p class="ghint">{{ $t("projects.writesImmediately") }}</p>
</section> </section>
<section class="sgroup"> <section class="sgroup instant">
<h4 class="eyebrow">{{ $t("projects.groupSession") }}</h4> <h4 class="eyebrow">{{ $t("projects.groupModules") }}</h4>
<label class="checkline todo-line"> <template v-for="m in settings.modules.filter((x) => !x.core)" :key="m.id">
<input v-model="settings.todo" type="checkbox" /> <label class="checkline">
<span>{{ $t("projects.todo") }} {{ $t("projects.todoDesc") }}</span> <input v-model="m.enabled" type="checkbox" @change="toggleModule(m)" />
<span>{{ $t(`modules.${m.id}`) }} {{ $t(`modules.${m.id}Desc`) }}</span>
</label> </label>
<div v-if="m.id === 'trilium'" class="srow">
<span class="slbl"></span>
<span class="sval"></span>
<span class="sacts">
<button @click="openTriliumWizard">{{ $t("trilium.wizard") }}</button>
</span>
</div>
</template>
<p class="ghint">{{ $t("projects.writesImmediately") }}</p>
</section> </section>
</div> </div>
<div class="actions"> <div class="actions">
@@ -812,6 +878,67 @@ onUnmounted(() => window.clearInterval(timer));
</div> </div>
</div> </div>
<div v-if="triWizard" class="overlay" @click.self="triWizard = null">
<div class="dialog tri-dialog">
<h3>{{ $t("trilium.wizardTitle") }}</h3>
<p class="hint">{{ $t("trilium.wizardIntro") }}</p>
<p v-if="triWizard.error" class="error">{{ triWizard.error }}</p>
<ul v-if="triWizard.status" class="tri-steps">
<li>
<span class="mark" :class="{ ok: triWizard.status.runtime_ready }">{{ triWizard.status.runtime_ready ? "✓" : "✗" }}</span>
<span>
{{ $t("trilium.stepRuntime") }}
<small>Trilium {{ triWizard.status.version }}</small>
</span>
</li>
<li>
<span class="mark" :class="{ ok: triWizard.status.instance_running }">{{ triWizard.status.instance_running ? "✓" : "✗" }}</span>
<span>
{{ $t("trilium.stepInstance") }}
<small>{{ $t("trilium.port", { port: triWizard.status.port }) }}</small>
</span>
</li>
<li>
<span class="mark" :class="{ ok: triWizard.status.token_ok }">{{ triWizard.status.token_ok ? "✓" : "✗" }}</span>
<span>{{ $t("trilium.stepToken") }}</span>
</li>
</ul>
<p v-if="triWizard.status" class="tri-meta">
{{ $t("trilium.dataDir") }}: <code>{{ triWizard.status.data_dir }}</code>
· http://127.0.0.1:{{ triWizard.status.port }}
</p>
<p class="hint">{{ $t("trilium.firstRunHint") }}</p>
<div v-if="triWizard.status && triWizard.status.instance_running && !triWizard.status.token_ok" class="tri-connect">
<span class="tri-lbl">{{ $t("trilium.password") }}</span>
<input
v-model="triWizard.password"
type="password"
@keydown.enter="triWizard.password && triliumConnect()"
/>
<button :disabled="triWizard.busy || !triWizard.password" @click="triliumConnect">
{{ $t("trilium.connect") }}
</button>
</div>
<div class="actions">
<button :disabled="triWizard.busy" @click="triliumSetup">
{{ triWizard.busy ? $t("trilium.working") : $t("trilium.setup") }}
</button>
<button
:disabled="triWizard.busy || !triWizard.status?.instance_running"
@click="triliumOpen"
>
{{ $t("trilium.open") }}
</button>
<button class="primary" @click="triWizard = null">{{ $t("trilium.close") }}</button>
</div>
</div>
</div>
<div v-if="pending" class="overlay"> <div v-if="pending" class="overlay">
<div class="dialog"> <div class="dialog">
<h3>{{ $t("projects.stillRunning", { name: pending.name }) }}</h3> <h3>{{ $t("projects.stillRunning", { name: pending.name }) }}</h3>
@@ -965,12 +1092,6 @@ onUnmounted(() => window.clearInterval(timer));
justify-content: flex-end; justify-content: flex-end;
} }
/* Todo-Zeile: volle Dialogbreite, der Text wird nie gekürzt. */
.todo-line {
font-size: 0.85rem;
color: var(--subtext);
}
.spath { .spath {
font-family: var(--mono); font-family: var(--mono);
font-size: 0.75rem; font-size: 0.75rem;
@@ -994,4 +1115,105 @@ onUnmounted(() => window.clearInterval(timer));
.settings-dialog .actions { .settings-dialog .actions {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Trilium-Wizard. Maße nach UI-Guideline (px statt rem): 14 px Basis für
Interaktives, Meta-Zeilen 12 px, Zeilen mindestens 28 px, 4-px-Skala. */
.tri-dialog {
width: min(40rem, 94vw);
max-width: none;
padding: 24px;
gap: 12px;
}
.tri-dialog h3 {
font-size: 16px;
}
.tri-dialog .hint,
.tri-dialog .error {
font-size: 13px;
line-height: 1.5;
}
.tri-steps {
list-style: none;
margin: 4px 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.tri-steps li {
display: flex;
gap: 12px;
align-items: flex-start;
min-height: 28px;
font-size: 14px;
color: var(--text);
}
.tri-steps li > span:last-child {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding-top: 2px;
}
.tri-steps small {
font-family: var(--mono);
font-size: 12px;
color: var(--subtext);
}
.tri-steps .mark {
width: 24px;
height: 24px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
border-radius: 6px;
background: var(--surface0);
color: var(--overlay);
}
.tri-steps .mark.ok {
color: var(--green, #a6e3a1);
}
.tri-meta {
margin: 0;
font-size: 12px;
color: var(--overlay);
}
.tri-meta code {
font-family: var(--mono);
font-size: 12px;
color: var(--subtext);
}
/* Verbinden-Zeile: Label | Passwort | Knopf. */
.tri-connect {
display: grid;
grid-template-columns: 8rem 1fr auto;
align-items: center;
gap: 12px;
min-height: 32px;
font-size: 14px;
color: var(--subtext);
}
.tri-connect input {
min-height: 28px;
font-size: 14px;
}
.tri-dialog .actions button {
min-height: 28px;
font-size: 14px;
}
</style> </style>
+7 -296
View File
@@ -1,308 +1,19 @@
/// vue-i18n-Wrapper für die Vue-Fenster (index.html). Die Tabellen liegen in
/// messages.ts und werden von der Terminal-/Panel-Schicht ohne Vue genutzt.
import { createI18n } from "vue-i18n"; import { createI18n } from "vue-i18n";
const de = { import { messages, storedLocale, type Locale } from "./messages";
app: {
projects: "Projekte",
pools: "Pools",
usage: "Verbrauch",
autostart: "Autostart",
},
usage: {
days7: "7 Tage",
days30: "30 Tage",
pool: "Pool",
project: "Projekt",
input: "Input",
output: "Output",
cacheWrite: "Cache ↑",
cacheRead: "Cache ↓",
cost: "≈ Kosten",
total: "Summe",
empty: "Keine Verbrauchsdaten im Zeitraum.",
estimateNote:
"API-Gegenwert aus den lokalen Transcripts — keine Abrechnung, Historie nur solange claude sie vorhält.",
},
projects: {
project: "Projekt",
pool: "Pool",
noPool: " kein Pool ",
start: "Starten",
stop: "Beenden",
terminalSettings: "Terminal-Einstellungen",
stillRunning: "{name} läuft noch",
poolChangeSaved:
"Der Pool-Wechsel ist gespeichert. Die laufende Session arbeitet aber weiter im alten Pool — der neue gilt erst ab dem nächsten Start.",
restartHint:
"Der Neustart beendet die laufende Session. Der automatische commit+push von claude-sync nach Sessionende entfällt dabei.",
keepRunning: "Weiterlaufen lassen",
restartNow: "Jetzt neu starten",
restarting: "Starte neu …",
terminal: "Terminal — {name}",
title: "Titel",
theme: "Theme",
dockIcon: "Dock-Icon",
chooseFile: "Datei wählen …",
remove: "Entfernen",
defaultIcon: "Standard-Icon",
appliesNextStart: "Gilt ab dem nächsten Terminal-Start.",
cancel: "Abbrechen",
save: "Speichern",
noPoolAssigned: "kein Pool",
needsPool: "Kein Pool zugeordnet",
needsKey: "Pool hat keinen API-Key",
newProject: "+ Projekt",
addFolder: "Projekt importieren …",
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
addWorkDir: "Ordner hinzufügen …",
archive: "Archiv",
archiveNone: "kein Archiv — Panel ohne Wiki und Suche",
changeDir: "Ändern …",
wizardTitle: "Neues Projekt",
name: "Name",
projectDir: "Projektordner",
projectDirCustom: "Ordner wählen …",
workDir: "Arbeitsordner",
workDirNone: "keiner",
workDirCustom: "vorhandenen Ordner verknüpfen",
chooseFolder: "Ordner wählen …",
create: "Anlegen",
wizardHint:
"Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json (Memory-Verzeichnis + Berechtigungen) und die Pool-/Terminal-Zuordnung.",
delete: "Projekt löschen",
deleteRunning: "Läuft noch — erst beenden",
deleteTitle: "{name} löschen",
deleteConfirm: "Entfernen",
deleteWorkDirs: "Arbeitsordner mitlöschen:",
scopeIntegration: "Nur Integration entfernen",
scopeIntegrationDesc:
"ai-control-Spuren werden entfernt; Projektordner, memory/ und Todoliste bleiben.",
scopeArchive: "Integration & Archiv",
scopeArchiveDesc: "zusätzlich {path} — endgültig.",
scopeFull: "Projekt komplett löschen",
scopeFullDesc: "zusätzlich der Projektordner {path} — endgültig.",
deletePreviewTitle: "Wird entfernt:",
docCount: "kein Dokument | 1 Dokument | {count} Dokumente",
artRegistry: "Registry-Eintrag",
artAiControl: ".ai-control/ (Config + Icon)",
artArchivePerm: "Archiv-Berechtigung in .claude/settings.json",
artTodoHook: "Todo-Hook in .claude/settings.json",
artPanelFiles: "Panel-Kanaldatei | {count} Panel-Kanaldateien",
artDesktop: ".desktop-Starter",
artArchive: "Archiv {path}",
artProjectDir: "Projektordner {path}",
artWorkDir: "Arbeitsordner {path}",
todo: "Todoliste",
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
groupAppearance: "Darstellung",
groupFolders: "Ordner",
groupSession: "Session",
writesImmediately: "Änderungen hier schreiben sofort — ohne Speichern.",
},
pools: {
pool: "Pool",
type: "Typ",
actions: "Aktionen",
newOauth: "+ oAuth",
newApikey: "+ apiKey",
relogin: "Zurücksetzen",
rename: "Umbenennen",
renameTitle: "{name} umbenennen",
changeKey: "Key ändern",
insertKey: "Key eintragen",
delete: "Löschen",
assigned: "kein Projekt | 1 Projekt | {count} Projekte",
empty: "Noch keine Pools angelegt.",
newOauthPool: "Neuen oAuth-Pool anlegen",
newApikeyPool: "Neuen API-Key-Pool anlegen",
editKey: "API-Key ändern {name}",
name: "Name",
apiKey: "API-Key",
oauthHint:
"Der Pool wird angelegt; die Anmeldung macht claude beim ersten Start selbst per /login (Browser gegen dein Abo).",
cancel: "Abbrechen",
createPool: "Anlegen",
reset: "Zurücksetzen",
save: "Speichern",
deletePool: "Pool löschen",
deleteWarning:
"Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.",
deleteUnassigns: "Diese Projekte verlieren ihre Pool-Zuordnung:",
deleteBlockedTooltip:
"Löschen gesperrt — {projects} läuft gerade. Erst beenden.",
reloginTitle: "{name} zurücksetzen",
reloginWarning:
"Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Beim nächsten Start verlangt claude erneut /login.",
reloginNoEntry:
"Kein Schlüsselbund-Eintrag vorhanden — beim nächsten Start meldet sich claude ohnehin per /login an.",
reloginBlocked:
"Zurücksetzen ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:",
keychainUnavailableTitle: "Keine sichere Ablage",
keychainUnavailable:
"Keychain/Keyring ist nicht verfügbar. Der Key kann stattdessen ungesichert als Datei im Pool-Ordner liegen (0600).",
storeAsFile: "Als Datei ablegen",
},
};
const en: typeof de = {
app: {
projects: "Projects",
pools: "Pools",
usage: "Usage",
autostart: "Autostart",
},
usage: {
days7: "7 days",
days30: "30 days",
pool: "Pool",
project: "Project",
input: "Input",
output: "Output",
cacheWrite: "Cache ↑",
cacheRead: "Cache ↓",
cost: "≈ cost",
total: "Total",
empty: "No usage data in this period.",
estimateNote:
"API equivalent from local transcripts — not a bill; history only as long as claude keeps it.",
},
projects: {
project: "Project",
pool: "Pool",
noPool: " no pool ",
start: "Start",
stop: "Stop",
terminalSettings: "Terminal settings",
stillRunning: "{name} is still running",
poolChangeSaved:
"The pool change is saved. The running session keeps working in the old pool — the new one applies from the next start.",
restartHint:
"Restarting ends the running session. The automatic commit+push by claude-sync after the session is skipped.",
keepRunning: "Keep running",
restartNow: "Restart now",
restarting: "Restarting …",
terminal: "Terminal — {name}",
title: "Title",
theme: "Theme",
dockIcon: "Dock icon",
chooseFile: "Choose file …",
remove: "Remove",
defaultIcon: "Default icon",
appliesNextStart: "Applies from the next terminal start.",
cancel: "Cancel",
save: "Save",
noPoolAssigned: "no pool",
needsPool: "No pool assigned",
needsKey: "Pool has no API key",
newProject: "+ Project",
addFolder: "Import project …",
addFolderTitle: "Add an existing folder as a project",
addWorkDir: "Add folder …",
archive: "Archive",
archiveNone: "no archive — panel without wiki and search",
changeDir: "Change …",
wizardTitle: "New project",
name: "Name",
projectDir: "Project folder",
projectDirCustom: "Choose folder …",
workDir: "Working folder",
workDirNone: "none",
workDirCustom: "link existing folder",
chooseFolder: "Choose folder …",
create: "Create",
wizardHint:
"Creates the project folder with memory/, sentinel .gitignore, settings.json (memory dir + permissions), and the pool/terminal assignment.",
delete: "Delete project",
deleteRunning: "Still running — stop it first",
deleteTitle: "Delete {name}",
deleteConfirm: "Remove",
deleteWorkDirs: "Also delete working folders:",
scopeIntegration: "Remove integration only",
scopeIntegrationDesc:
"ai-control traces are removed; project folder, memory/ and todo list stay.",
scopeArchive: "Integration & archive",
scopeArchiveDesc: "additionally {path} — permanent.",
scopeFull: "Delete project completely",
scopeFullDesc: "additionally the project folder {path} — permanent.",
deletePreviewTitle: "Will be removed:",
docCount: "no documents | 1 document | {count} documents",
artRegistry: "registry entry",
artAiControl: ".ai-control/ (config + icon)",
artArchivePerm: "archive permission in .claude/settings.json",
artTodoHook: "todo hook in .claude/settings.json",
artPanelFiles: "panel channel file | {count} panel channel files",
artDesktop: ".desktop launcher",
artArchive: "archive {path}",
artProjectDir: "project folder {path}",
artWorkDir: "working folder {path}",
todo: "Todo list",
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
groupAppearance: "Appearance",
groupFolders: "Folders",
groupSession: "Session",
writesImmediately: "Changes here are written immediately — no save step.",
},
pools: {
pool: "Pool",
type: "Type",
actions: "Actions",
newOauth: "+ oAuth",
newApikey: "+ apiKey",
relogin: "Reset",
rename: "Rename",
renameTitle: "Rename {name}",
changeKey: "Change key",
insertKey: "Insert key",
delete: "Delete",
assigned: "no projects | 1 project | {count} projects",
empty: "No pools yet.",
newOauthPool: "Create oAuth pool",
newApikeyPool: "Create API key pool",
editKey: "Change API key {name}",
name: "Name",
apiKey: "API key",
oauthHint:
"The pool is created; claude signs in itself on first start via /login (browser against your subscription).",
cancel: "Cancel",
createPool: "Create",
reset: "Reset",
save: "Save",
deletePool: "Delete pool",
deleteWarning:
"Pool {name} and its credential file will be deleted. This cannot be undone.",
deleteUnassigns: "These projects lose their pool assignment:",
deleteBlockedTooltip:
"Deletion blocked — active: {projects}. Stop the session first.",
reloginTitle: "Reset {name}",
reloginWarning:
"The access token for {name} stored in the keychain will be deleted. On the next start claude asks for /login again.",
reloginNoEntry:
"No keychain entry present — claude signs in via /login on the next start anyway.",
reloginBlocked:
"Resetting requires an unused pool. These projects are still running:",
keychainUnavailableTitle: "No secure storage",
keychainUnavailable:
"Keychain/keyring is unavailable. The key can instead be stored unprotected as a file in the pool folder (0600).",
storeAsFile: "Store as file",
},
};
const stored = localStorage.getItem("lang");
const initial =
stored === "de" || stored === "en"
? stored
: navigator.language.startsWith("de")
? "de"
: "en";
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
globalInjection: true, globalInjection: true,
locale: initial, locale: storedLocale(),
fallbackLocale: "en", fallbackLocale: "en",
messages: { de, en }, messages,
}); });
export function setLocale(lang: "de" | "en") { export function setLocale(lang: Locale) {
i18n.global.locale.value = lang; i18n.global.locale.value = lang;
localStorage.setItem("lang", lang); localStorage.setItem("lang", lang);
} }
+126
View File
@@ -0,0 +1,126 @@
// Übersetzungstabellen und der Vue-freie t()/applyI18n()-Helfer.
import { afterEach, describe, expect, it } from "vitest";
import { applyI18n, messages, storedLocale, t } from "./messages";
/// Alle Punkt-Pfade eines Nachrichtenbaums, für den Vollständigkeitsabgleich.
function keys(obj: object, prefix = ""): string[] {
return Object.entries(obj).flatMap(([k, v]) =>
typeof v === "object" && v !== null
? keys(v, `${prefix}${k}.`)
: [`${prefix}${k}`],
);
}
afterEach(() => {
try {
window.localStorage.removeItem("lang");
} catch {
/* Storage in der Testumgebung nicht verfügbar */
}
document.body.textContent = "";
});
describe("Nachrichtentabellen", () => {
it("führen dieselben Schlüssel in beiden Sprachen", () => {
expect(keys(messages.en).sort()).toEqual(keys(messages.de).sort());
});
it("lassen keinen Text unübersetzt stehen", () => {
// Gleiche Texte sind fast immer eine vergessene Übersetzung. Erlaubt sind
// nur Eigennamen und Zeichen ohne Sprache.
const erlaubt = new Set([
"Pool",
"Pools",
"Panel",
"Name",
"Theme",
"Wiki",
"ToDo",
"MD",
"Autostart",
"Input",
"Output",
"Session",
"Cache ↑",
"Cache ↓",
"+ oAuth",
"+ apiKey",
"+ ToDo",
"System",
"Trilium",
"Terminal",
"Terminal — {name}",
".desktop-Starter",
".ai-control/ (Config + Icon)",
"Archiv-Berechtigung in .claude/settings.json",
]);
const gleich = keys(messages.de).filter((k) => {
const de = pick(messages.de, k);
return de === pick(messages.en, k) && !erlaubt.has(de);
});
expect(gleich).toEqual([]);
});
});
function pick(obj: object, key: string): string {
return key
.split(".")
.reduce<never>((o, part) => (o as Record<string, never>)[part], obj as never);
}
describe("t()", () => {
it("löst Punkt-Pfade auf", () => {
expect(t("panel.tabSearch")).toBe("Suche");
});
it("setzt Platzhalter ein", () => {
expect(t("search.noHits", { scope: "„x“" })).toBe("Keine Treffer für „x“");
});
it("lässt unbekannte Platzhalter stehen", () => {
expect(t("search.noHits", { falsch: "x" })).toBe("Keine Treffer für {scope}");
});
it("gibt bei fehlendem Schlüssel den Pfad zurück", () => {
expect(t("panel.gibtEsNicht")).toBe("panel.gibtEsNicht");
expect(t("nichts.da.tief")).toBe("nichts.da.tief");
});
it("folgt der gespeicherten Sprache", () => {
window.localStorage.setItem("lang", "en");
expect(storedLocale()).toBe("en");
expect(t("panel.tabSearch")).toBe("Search");
});
it("fällt bei unbekannter Sprache auf die Browsersprache zurück", () => {
window.localStorage.setItem("lang", "kli");
expect(storedLocale()).toBe("de"); // test-setup.ts pinnt navigator.language
});
});
describe("applyI18n()", () => {
it("beschriftet Text und Attribute", () => {
document.body.innerHTML = `
<button data-i18n="panel.tabSearch" data-i18n-title="panel.tabSearchTitle">alt</button>
<button data-i18n-aria="panel.close"></button>
<input data-i18n-placeholder="search.placeholder" />`;
applyI18n();
const btn = document.querySelector("button")!;
expect(btn.textContent).toBe("Suche");
expect(btn.title).toBe("Suchtreffer");
expect(document.querySelectorAll("button")[1].getAttribute("aria-label")).toBe(
"Schließen",
);
expect(document.querySelector("input")!.placeholder).toBe(
"Archiv durchsuchen — #tag filtert",
);
expect(document.documentElement.lang).toBe("de");
});
it("lässt Markup ohne Marker unangetastet", () => {
document.body.innerHTML = `<span>unberührt</span>`;
applyI18n();
expect(document.querySelector("span")!.textContent).toBe("unberührt");
});
});
+502
View File
@@ -0,0 +1,502 @@
/// Übersetzungstabellen für alle Fenster plus ein Vue-freier `t()`-Helfer.
/// Die Vue-Fenster (index.html) ziehen die Tabellen über i18n.ts in vue-i18n;
/// Terminal-, Panel- und Popup-Fenster haben kein Vue und nutzen `t()` und
/// `applyI18n()` direkt — sonst landete vue-i18n samt Vue in ihren Bundles.
export type Locale = "de" | "en";
const de = {
app: {
projects: "Projekte",
pools: "Pools",
usage: "Verbrauch",
autostart: "Autostart",
},
usage: {
days7: "7 Tage",
days30: "30 Tage",
pool: "Pool",
project: "Projekt",
input: "Input",
output: "Output",
cacheWrite: "Cache ↑",
cacheRead: "Cache ↓",
cost: "≈ Kosten",
total: "Summe",
empty: "Keine Verbrauchsdaten im Zeitraum.",
estimateNote:
"API-Gegenwert aus den lokalen Transcripts — keine Abrechnung, Historie nur solange claude sie vorhält.",
},
projects: {
project: "Projekt",
pool: "Pool",
noPool: " kein Pool ",
start: "Starten",
stop: "Beenden",
terminalSettings: "Terminal-Einstellungen",
openArchive: "Archiv öffnen (Trilium)",
stillRunning: "{name} läuft noch",
poolChangeSaved:
"Der Pool-Wechsel ist gespeichert. Die laufende Session arbeitet aber weiter im alten Pool — der neue gilt erst ab dem nächsten Start.",
restartHint:
"Der Neustart beendet die laufende Session. Der automatische commit+push von claude-sync nach Sessionende entfällt dabei.",
keepRunning: "Weiterlaufen lassen",
restartNow: "Jetzt neu starten",
restarting: "Starte neu …",
terminal: "Terminal — {name}",
title: "Titel",
theme: "Theme",
dockIcon: "Dock-Icon",
chooseFile: "Datei wählen …",
remove: "Entfernen",
defaultIcon: "Standard-Icon",
appliesNextStart: "Gilt ab dem nächsten Terminal-Start.",
cancel: "Abbrechen",
save: "Speichern",
noPoolAssigned: "kein Pool",
needsPool: "Kein Pool zugeordnet",
needsKey: "Pool hat keinen API-Key",
newProject: "+ Projekt",
addFolder: "Projekt importieren …",
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
addWorkDir: "Ordner hinzufügen …",
changeDir: "Ändern …",
wizardTitle: "Neues Projekt",
name: "Name",
projectDir: "Projektordner",
projectDirCustom: "Ordner wählen …",
workDir: "Arbeitsordner",
workDirNone: "keiner",
workDirCustom: "vorhandenen Ordner verknüpfen",
chooseFolder: "Ordner wählen …",
create: "Anlegen",
wizardHint:
"Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json (Memory-Verzeichnis + Berechtigungen) und die Pool-/Terminal-Zuordnung.",
delete: "Projekt löschen",
deleteRunning: "Läuft noch — erst beenden",
deleteTitle: "{name} löschen",
deleteConfirm: "Entfernen",
deleteWorkDirs: "Arbeitsordner mitlöschen:",
scopeIntegration: "Nur Integration entfernen",
scopeIntegrationDesc:
"ai-control-Spuren werden entfernt; Projektordner, memory/ und Todoliste bleiben.",
scopeArchive: "Integration & Archiv",
scopeArchiveDesc: "zusätzlich das Trilium-Archiv {path} — endgültig.",
scopeFull: "Projekt komplett löschen",
scopeFullDesc: "zusätzlich der Projektordner {path} — endgültig.",
deletePreviewTitle: "Wird entfernt:",
artRegistry: "Registry-Eintrag",
artAiControl: ".ai-control/ (Config + Icon)",
artPanelFiles: "Panel-Kanaldatei | {count} Panel-Kanaldateien",
artDesktop: ".desktop-Starter",
artArchive: "Trilium-Archiv {path}",
artProjectDir: "Projektordner {path}",
artWorkDir: "Arbeitsordner {path}",
groupAppearance: "Darstellung",
groupFolders: "Ordner",
groupModules: "Module",
writesImmediately: "Änderungen hier schreiben sofort — ohne Speichern.",
},
modules: {
commands: "Befehle",
commandsDesc: "Befehls-Kacheln im Panel (write_commands)",
todo: "ToDo-Liste",
todoDesc: "persistente Aufgaben-Kacheln mit Fälligkeits-Ampel; öffnet beim Sessionstart",
trilium: "Trilium",
triliumDesc:
"Archiv als eigene Trilium-Instanz — Archivieren, Suche und Notizen laufen über Trilium",
},
trilium: {
wizardTitle: "Trilium einrichten",
wizardIntro:
"Richtet das Projekt-Archiv als eigene Trilium-Instanz ein: Runtime versionsgepinnt geladen, Instanz nur auf 127.0.0.1 mit eigenem Datenverzeichnis und Port. Das Modul wird erst nach erfolgreicher Verbindung aktiviert.",
stepRuntime: "Runtime vorhanden",
stepInstance: "Instanz läuft",
stepToken: "Verbunden (ETAPI-Token)",
port: "Port {port}",
dataDir: "Daten",
firstRunHint:
"Beim ersten Verbinden legt die App das Dokument an und setzt das gewählte Passwort. Gespeichert wird nur der ETAPI-Token (Keyring), nie das Passwort.",
password: "Passwort",
connect: "Verbinden",
setup: "Einrichten / Starten",
working: "Läuft …",
open: "Trilium öffnen",
close: "Schließen",
wizard: "Einrichtung / Status",
},
todos: {
copyOne: "Aufgabe kopieren",
editOne: "Aufgabe bearbeiten",
removeOne: "Aufgabe entfernen",
add: "+ ToDo",
addTitle: "Neue Aufgabe anlegen",
formText: "Aufgabe",
formNote: "Notiz",
formDue: "Fälligkeit",
formCreate: "Anlegen",
formSave: "Speichern",
formCancel: "Abbrechen",
},
pools: {
pool: "Pool",
type: "Typ",
actions: "Aktionen",
newOauth: "+ oAuth",
newApikey: "+ apiKey",
relogin: "Zurücksetzen",
rename: "Umbenennen",
renameTitle: "{name} umbenennen",
changeKey: "Key ändern",
insertKey: "Key eintragen",
delete: "Löschen",
assigned: "kein Projekt | 1 Projekt | {count} Projekte",
empty: "Noch keine Pools angelegt.",
newOauthPool: "Neuen oAuth-Pool anlegen",
newApikeyPool: "Neuen API-Key-Pool anlegen",
editKey: "API-Key ändern {name}",
name: "Name",
apiKey: "API-Key",
oauthHint:
"Der Pool wird angelegt; die Anmeldung macht claude beim ersten Start selbst per /login (Browser gegen dein Abo).",
newReference: "+ vorhandene Anmeldung",
newReferencePool: "Vorhandene Anmeldung übernehmen",
referenceDefaultName: "System",
referenceHint:
"Der Pool verweist auf {dir} — claudes eigenes Verzeichnis. Die dortige Anmeldung gilt weiter, ein /login entfällt. Die App legt dort nur den Panel-Zugang an und löscht nichts.",
referenceLogin: "Anmeldung von claude",
cancel: "Abbrechen",
createPool: "Anlegen",
reset: "Zurücksetzen",
save: "Speichern",
deletePool: "Pool löschen",
deleteWarning:
"Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.",
deleteUnassigns: "Diese Projekte verlieren ihre Pool-Zuordnung:",
deleteBlockedTooltip:
"Löschen gesperrt — {projects} läuft gerade. Erst beenden.",
reloginTitle: "{name} zurücksetzen",
reloginWarning:
"Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Beim nächsten Start verlangt claude erneut /login.",
reloginNoEntry:
"Kein Schlüsselbund-Eintrag vorhanden — beim nächsten Start meldet sich claude ohnehin per /login an.",
reloginBlocked:
"Zurücksetzen ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:",
keychainUnavailableTitle: "Keine sichere Ablage",
keychainUnavailable:
"Keychain/Keyring ist nicht verfügbar. Der Key kann stattdessen ungesichert als Datei im Pool-Ordner liegen (0600).",
storeAsFile: "Als Datei ablegen",
},
panel: {
windowTerminal: "Terminal",
windowPanel: "Panel",
windowProjects: "Projekte",
tabCommands: "Befehle",
tabCommandsTitle: "Befehls-History",
tabTodo: "ToDo",
tabTodoTitle: "ToDo-Liste des Projekts",
tabSearch: "Suche",
archiveOpen: "Archiv",
archiveOpenTitle: "Archiv in Trilium öffnen",
tabSearchTitle: "Suchtreffer",
minimize: "Minimieren",
maximize: "Maximieren",
close: "Schließen",
closePanel: "Panel schließen",
hidePanel: "Panel ausblenden",
detach: "In eigenes Fenster ablösen",
dock: "Wieder andocken",
processEnded: "[Prozess beendet]",
},
popup: {
open: "Öffnen",
quit: "Beenden",
},
commands: {
copyOne: "Befehl kopieren",
removeOne: "Aus der History entfernen",
copyAll: "Alle kopieren",
session: "Session",
},
search: {
placeholder: "Archiv durchsuchen — #tag filtert",
minChars: "Mindestens 3 Zeichen — oder Enter.",
hits: "{count} Treffer {scope}",
noHits: "Keine Treffer für {scope}",
},
};
const en: typeof de = {
app: {
projects: "Projects",
pools: "Pools",
usage: "Usage",
autostart: "Autostart",
},
usage: {
days7: "7 days",
days30: "30 days",
pool: "Pool",
project: "Project",
input: "Input",
output: "Output",
cacheWrite: "Cache ↑",
cacheRead: "Cache ↓",
cost: "≈ cost",
total: "Total",
empty: "No usage data in this period.",
estimateNote:
"API equivalent from local transcripts — not a bill; history only as long as claude keeps it.",
},
projects: {
project: "Project",
pool: "Pool",
noPool: " no pool ",
start: "Start",
stop: "Stop",
terminalSettings: "Terminal settings",
openArchive: "Open archive (Trilium)",
stillRunning: "{name} is still running",
poolChangeSaved:
"The pool change is saved. The running session keeps working in the old pool — the new one applies from the next start.",
restartHint:
"Restarting ends the running session. The automatic commit+push by claude-sync after the session is skipped.",
keepRunning: "Keep running",
restartNow: "Restart now",
restarting: "Restarting …",
terminal: "Terminal — {name}",
title: "Title",
theme: "Theme",
dockIcon: "Dock icon",
chooseFile: "Choose file …",
remove: "Remove",
defaultIcon: "Default icon",
appliesNextStart: "Applies from the next terminal start.",
cancel: "Cancel",
save: "Save",
noPoolAssigned: "no pool",
needsPool: "No pool assigned",
needsKey: "Pool has no API key",
newProject: "+ Project",
addFolder: "Import project …",
addFolderTitle: "Add an existing folder as a project",
addWorkDir: "Add folder …",
changeDir: "Change …",
wizardTitle: "New project",
name: "Name",
projectDir: "Project folder",
projectDirCustom: "Choose folder …",
workDir: "Working folder",
workDirNone: "none",
workDirCustom: "link existing folder",
chooseFolder: "Choose folder …",
create: "Create",
wizardHint:
"Creates the project folder with memory/, sentinel .gitignore, settings.json (memory dir + permissions), and the pool/terminal assignment.",
delete: "Delete project",
deleteRunning: "Still running — stop it first",
deleteTitle: "Delete {name}",
deleteConfirm: "Remove",
deleteWorkDirs: "Also delete working folders:",
scopeIntegration: "Remove integration only",
scopeIntegrationDesc:
"ai-control traces are removed; project folder, memory/ and todo list stay.",
scopeArchive: "Integration & archive",
scopeArchiveDesc: "additionally the Trilium archive {path} — permanent.",
scopeFull: "Delete project completely",
scopeFullDesc: "additionally the project folder {path} — permanent.",
deletePreviewTitle: "Will be removed:",
artRegistry: "registry entry",
artAiControl: ".ai-control/ (config + icon)",
artPanelFiles: "panel channel file | {count} panel channel files",
artDesktop: ".desktop launcher",
artArchive: "Trilium archive {path}",
artProjectDir: "project folder {path}",
artWorkDir: "working folder {path}",
groupAppearance: "Appearance",
groupFolders: "Folders",
groupModules: "Modules",
writesImmediately: "Changes here are written immediately — no save step.",
},
modules: {
commands: "Commands",
commandsDesc: "command tiles in the panel (write_commands)",
todo: "ToDo list",
todoDesc: "persistent task tiles with due-date signal; opens at session start",
trilium: "Trilium",
triliumDesc:
"archive as a dedicated Trilium instance — archiving, search and notes run through Trilium",
},
trilium: {
wizardTitle: "Set up Trilium",
wizardIntro:
"Sets up the project archive as its own Trilium instance: version-pinned runtime download, instance on 127.0.0.1 only with its own data directory and port. The module is enabled only after a successful connection.",
stepRuntime: "Runtime present",
stepInstance: "Instance running",
stepToken: "Connected (ETAPI token)",
port: "port {port}",
dataDir: "Data",
firstRunHint:
"On first connect the app creates the document and sets the chosen password. Only the ETAPI token is stored (keyring), never the password.",
password: "Password",
connect: "Connect",
setup: "Set up / start",
working: "Working …",
open: "Open Trilium",
close: "Close",
wizard: "Setup / status",
},
todos: {
copyOne: "Copy task",
editOne: "Edit task",
removeOne: "Remove task",
add: "+ ToDo",
addTitle: "Add a task",
formText: "Task",
formNote: "Note",
formDue: "Due date",
formCreate: "Add",
formSave: "Save",
formCancel: "Cancel",
},
pools: {
pool: "Pool",
type: "Type",
actions: "Actions",
newOauth: "+ oAuth",
newApikey: "+ apiKey",
relogin: "Reset",
rename: "Rename",
renameTitle: "Rename {name}",
changeKey: "Change key",
insertKey: "Insert key",
delete: "Delete",
assigned: "no projects | 1 project | {count} projects",
empty: "No pools yet.",
newOauthPool: "Create oAuth pool",
newApikeyPool: "Create API key pool",
editKey: "Change API key {name}",
name: "Name",
apiKey: "API key",
oauthHint:
"The pool is created; claude signs in itself on first start via /login (browser against your subscription).",
newReference: "+ existing login",
newReferencePool: "Adopt existing login",
referenceDefaultName: "System",
referenceHint:
"The pool points at {dir} — claude's own directory. The login there keeps working, so no /login is needed. The app only adds panel access there and deletes nothing.",
referenceLogin: "login from claude",
cancel: "Cancel",
createPool: "Create",
reset: "Reset",
save: "Save",
deletePool: "Delete pool",
deleteWarning:
"Pool {name} and its credential file will be deleted. This cannot be undone.",
deleteUnassigns: "These projects lose their pool assignment:",
deleteBlockedTooltip:
"Deletion blocked — active: {projects}. Stop the session first.",
reloginTitle: "Reset {name}",
reloginWarning:
"The access token for {name} stored in the keychain will be deleted. On the next start claude asks for /login again.",
reloginNoEntry:
"No keychain entry present — claude signs in via /login on the next start anyway.",
reloginBlocked:
"Resetting requires an unused pool. These projects are still running:",
keychainUnavailableTitle: "No secure storage",
keychainUnavailable:
"Keychain/keyring is unavailable. The key can instead be stored unprotected as a file in the pool folder (0600).",
storeAsFile: "Store as file",
},
panel: {
windowTerminal: "Terminal",
windowPanel: "Panel",
windowProjects: "Projects",
tabCommands: "Commands",
tabCommandsTitle: "Command history",
tabTodo: "ToDo",
tabTodoTitle: "Project to-do list",
tabSearch: "Search",
archiveOpen: "Archive",
archiveOpenTitle: "Open the archive in Trilium",
tabSearchTitle: "Search hits",
minimize: "Minimize",
maximize: "Maximize",
close: "Close",
closePanel: "Close panel",
hidePanel: "Hide panel",
detach: "Detach into its own window",
dock: "Dock again",
processEnded: "[process ended]",
},
popup: {
open: "Open",
quit: "Quit",
},
commands: {
copyOne: "Copy command",
removeOne: "Remove from history",
copyAll: "Copy all",
session: "Session",
},
search: {
placeholder: "Search the archive — #tag filters",
minChars: "At least 3 characters — or press Enter.",
hits: "{count} hits {scope}",
noHits: "No hits for {scope}",
},
};
export const messages = { de, en };
/// Gewählte Sprache: der Schalter aus dem Hauptfenster (localStorage), sonst
/// die Browser-Sprache. Beide Wege teilen sich den Schlüssel `lang` mit
/// setLocale() in i18n.ts.
export function storedLocale(): Locale {
// Über window und in try/catch: gesperrter Storage (privater Modus) wirft
// schon beim Lesen, und unter Node schattet ein undefiniertes Global das
// localStorage der Testumgebung.
let stored: string | null = null;
try {
stored = window.localStorage.getItem("lang");
} catch {
stored = null;
}
if (stored === "de" || stored === "en") return stored;
return navigator.language.startsWith("de") ? "de" : "en";
}
/// Übersetzt einen Punkt-Pfad (`panel.tabSearch`) und setzt `{platzhalter}` ein.
/// Fehlt der Schlüssel, kommt der Pfad selbst zurück — im UI sichtbar statt
/// still leer.
export function t(key: string, params?: Record<string, string | number>): string {
const table: unknown = messages[storedLocale()];
const hit = key
.split(".")
.reduce<unknown>((o, part) => (o as Record<string, unknown>)?.[part], table);
if (typeof hit !== "string") return key;
return params
? hit.replace(/\{(\w+)\}/g, (m, name) => String(params[name] ?? m))
: hit;
}
/// Beschriftet statisches Markup: `data-i18n` setzt den Textinhalt (auch am
/// <title> im Kopf), `data-i18n-title`/`-aria`/`-placeholder` das jeweilige
/// Attribut. Einmal beim Fensterstart aufgerufen — die Sprache wechselt erst
/// mit dem nächsten Start, es gibt in diesen Fenstern keinen Umschalter.
export function applyI18n(root: ParentNode = document) {
for (const el of root.querySelectorAll<HTMLElement>("[data-i18n]")) {
el.textContent = t(el.dataset.i18n!);
}
const attrs: [string, string][] = [
["data-i18n-title", "title"],
["data-i18n-aria", "aria-label"],
["data-i18n-placeholder", "placeholder"],
];
for (const [marker, attr] of attrs) {
for (const el of root.querySelectorAll<HTMLElement>(`[${marker}]`)) {
el.setAttribute(attr, t(el.getAttribute(marker)!));
}
}
document.documentElement.lang = storedLocale();
}
+30
View File
@@ -0,0 +1,30 @@
import { invoke } from "@tauri-apps/api/core";
import { initSearchView } from "../search-view";
import type { PanelTab } from "./index";
export const searchTab: PanelTab = {
mode: "search",
module: "trilium",
buffer: "search",
labelKey: "panel.tabSearch",
titleKey: "panel.tabSearchTitle",
sepAfter: true,
// Treffer-Klick öffnet die Notiz im Trilium-Fenster des Projekts.
init: (container, ctx) =>
initSearchView(
container,
(noteId) =>
void invoke("trilium_open", { project: ctx.project, noteId }).catch((e) =>
ctx.toast(String(e)),
),
(raw) => {
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
const words = raw.split(/\s+/).filter(Boolean);
const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null;
const query = words.filter((w) => !w.startsWith("#")).join(" ");
void invoke("search_run", { project: ctx.project, query, tag }).catch((e) =>
ctx.toast(`Suche fehlgeschlagen: ${e}`),
);
},
),
};
+16
View File
@@ -0,0 +1,16 @@
import { invoke } from "@tauri-apps/api/core";
import { initCommandsView } from "../commands-view";
import type { PanelTab } from "./index";
export const commandsTab: PanelTab = {
mode: "commands",
module: "commands",
buffer: "commands",
labelKey: "panel.tabCommands",
titleKey: "panel.tabCommandsTitle",
sepAfter: true,
init: (container, ctx) =>
initCommandsView(container, (id) =>
invoke("commands_delete", { project: ctx.project, id }),
),
};
+42
View File
@@ -0,0 +1,42 @@
/// Modul-Registry des Panels: welche Tabs es gibt, zu welchem Backend-Modul
/// sie gehören und wie ihre Ansicht entsteht. panel-wiring baut Tab-Knöpfe
/// und Content-Container aus dieser Liste; welche Tabs erscheinen,
/// entscheidet `enabled_modules` (Backend-Registry in domain/modules.rs).
/// Puffer-Konvention: Erstbefüllung per `buffer_read`, Updates kommen als
/// `<buffer>-update`-Event.
import { commandsTab } from "./commands";
import { searchTab } from "./archive";
import { todoTab } from "./todo";
export interface ModuleView {
set(text: string): void;
empty(): boolean;
}
/// Gemeinsame Dienste für Modul-Ansichten — alles, was mehr als ein Modul
/// braucht; Modulspezifisches (eigene Invokes) macht der Deskriptor selbst.
export interface ModuleCtx {
project: string;
toast(msg: string): void;
}
export interface PanelTab {
/// data-mode des Tab-Knopfs und ID-Präfix des Containers (`<mode>-content`,
/// zugleich CSS-Anker der Ansicht).
mode: string;
/// Backend-Modul (enabled_modules) — bestimmt, ob der Tab erscheint.
module: string;
/// Puffer-ID für buffer_read; Updates als `<buffer>-update`.
buffer: string;
labelKey: string;
titleKey: string;
/// Visueller Trenner hinter dem Tab.
sepAfter?: boolean;
/// Baut die Ansicht des Tabs.
init(container: HTMLElement, ctx: ModuleCtx): ModuleView;
/// Nach Tab-Klick.
onActivate?(view: ModuleView, ctx: ModuleCtx): void;
}
export const PANEL_TABS: PanelTab[] = [todoTab, commandsTab, searchTab];
+29
View File
@@ -0,0 +1,29 @@
import { invoke } from "@tauri-apps/api/core";
import { initTodoView, type TodoEdit } from "../todo-view";
import type { PanelTab } from "./index";
export const todoTab: PanelTab = {
mode: "todo",
module: "todo",
buffer: "todos",
labelKey: "panel.tabTodo",
titleKey: "panel.tabTodoTitle",
init: (container, ctx) =>
initTodoView(
container,
(id) => invoke("todos_delete", { project: ctx.project, id }),
(todo: TodoEdit) => {
const args = {
project: ctx.project,
text: todo.text,
note: todo.note ?? null,
due: todo.due ?? null,
};
void (
todo.id
? invoke("todos_update", { ...args, id: todo.id })
: invoke("todos_add", args)
).catch((e) => ctx.toast(String(e)));
},
),
};
+137 -299
View File
@@ -92,6 +92,143 @@
system-ui, system-ui,
sans-serif; sans-serif;
} }
/* ToDo-Kacheln: Layout wie Befehls-Kacheln (cmd-tile/cmd-body), eigener
Text-Stil und Fälligkeits-Ampel. */
#todo-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 14px;
}
#todo-content::-webkit-scrollbar {
width: 8px;
}
#todo-content::-webkit-scrollbar-thumb {
background: var(--line);
border-radius: 4px;
}
.todo-text {
font:
400 12px/1.45 -apple-system,
system-ui,
sans-serif;
word-break: break-word;
user-select: text;
-webkit-user-select: text;
cursor: text;
}
.todo-due {
display: inline-block;
margin-top: 5px;
border-radius: 999px;
padding: 2px 8px;
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px;
font-weight: 500;
}
.todo-due.overdue {
color: var(--err);
border: 1px solid var(--err);
}
.todo-due.soon {
color: var(--warn);
border: 1px solid var(--warn);
}
.todo-due.later {
color: var(--ok);
border: 1px solid var(--ok);
}
/* Kopfzeile mit Anlegen-Knopf und das ToDo-Formular (Anlegen/Bearbeiten),
in-flow über der Liste. */
.todo-head {
display: flex;
justify-content: flex-end;
}
.todo-add {
border: none;
background: transparent;
color: var(--muted);
font:
500 11px/1 -apple-system,
system-ui,
sans-serif;
border-radius: 6px;
padding: 4px 6px;
}
.todo-add:hover {
background: var(--line);
color: var(--text);
}
.todo-form {
display: flex;
flex-direction: column;
gap: 6px;
background: var(--tile);
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 10px;
margin-top: 6px;
}
.todo-form[hidden] {
display: none;
}
.todo-form input {
box-sizing: border-box;
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
color: var(--text);
font:
400 12px/1.4 -apple-system,
system-ui,
sans-serif;
outline: none;
}
.todo-form input:focus {
border-color: var(--line-strong);
}
.todo-form input::placeholder {
color: var(--faint);
}
.todo-form input[type="date"] {
color-scheme: dark;
}
.todo-form-btns {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.todo-form-cancel {
border: 1px solid var(--line);
background: none;
color: var(--text);
border-radius: 6px;
padding: 6px 10px;
font:
500 12px/1.4 -apple-system,
system-ui,
sans-serif;
}
.todo-form-cancel:hover {
background: var(--line);
}
.todo-form-submit {
border: none;
background: var(--line);
color: var(--text);
border-radius: 6px;
padding: 6px 10px;
font:
500 12px/1.4 -apple-system,
system-ui,
sans-serif;
}
.todo-form-submit:hover {
background: var(--line-strong);
}
#search-content { #search-content {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -174,305 +311,6 @@
font-size: 10px; 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: var(--line);
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: var(--text);
font:
600 13px/1.3 -apple-system,
system-ui,
sans-serif;
}
.wiki-head-sub {
margin-top: 2px;
color: var(--faint);
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wiki-head-right {
color: var(--faint);
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 var(--line);
background: transparent;
color: var(--muted);
border-radius: 999px;
padding: 2px 8px;
font:
500 10.5px/1.5 -apple-system,
system-ui,
sans-serif;
}
.wiki-chip:hover {
background: var(--line);
color: var(--text);
}
.wiki-chip.active {
background: var(--line);
border-color: var(--line-strong);
color: var(--accent);
}
.wiki-folder {
margin: 14px 0 2px;
padding-top: 10px;
border-top: 1px solid var(--line);
color: var(--faint);
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: var(--tile);
}
.wiki-doc-line {
display: flex;
align-items: baseline;
gap: 8px;
}
.wiki-doc-title {
flex: 1;
min-width: 0;
color: var(--accent);
font:
600 12px/1.4 -apple-system,
system-ui,
sans-serif;
}
.wiki-doc-date {
color: var(--faint);
font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px;
}
.wiki-doc-desc {
margin-top: 2px;
color: var(--muted);
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: var(--muted);
font:
400 12px/1.6 -apple-system,
system-ui,
sans-serif;
}
.wiki-empty strong {
display: block;
margin-bottom: 6px;
color: var(--text);
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: var(--muted);
font:
500 11px/1 -apple-system,
system-ui,
sans-serif;
border-radius: 6px;
padding: 4px 6px;
}
.wiki-back:hover {
background: var(--line);
color: var(--text);
}
.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: var(--line);
border-radius: 4px;
padding: 1px 5px;
}
.wiki-body pre {
background: var(--tile);
border-radius: 8px;
padding: 12px 14px;
overflow: auto;
}
.wiki-body pre code {
background: none;
padding: 0;
}
.wiki-body blockquote {
border-left: 3px solid var(--line);
padding-left: 12px;
color: var(--muted);
}
.wiki-body a {
color: var(--accent);
}
.wiki-backlinks {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid var(--line);
color: var(--faint);
font:
500 11px/1.6 -apple-system,
system-ui,
sans-serif;
}
.wiki-backlinks a {
color: var(--accent);
text-decoration: none;
}
/* Backlink-Zähler in der Dokumentzeile. */
.wiki-doc-back {
color: var(--faint);
font:
500 10px/1 -apple-system,
system-ui,
sans-serif;
white-space: nowrap;
}
/* Archiv-Formular: klappt unter dem Archiv-Button auf. */
.archive-form {
position: fixed;
z-index: 20;
display: flex;
flex-direction: column;
gap: 6px;
width: 260px;
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.archive-form[hidden] {
display: none;
}
.archive-form input {
box-sizing: border-box;
width: 100%;
background: var(--tile);
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
color: var(--text);
font:
400 12px/1.4 -apple-system,
system-ui,
sans-serif;
outline: none;
}
.archive-form input:focus {
border-color: var(--line-strong);
}
.archive-form input::placeholder {
color: var(--faint);
}
.archive-form-submit {
border: none;
background: var(--line);
color: var(--text);
border-radius: 6px;
padding: 6px 10px;
font:
500 12px/1.4 -apple-system,
system-ui,
sans-serif;
}
.archive-form-submit:hover {
background: var(--line-strong);
}
/* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */ /* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */
.panel-toast { .panel-toast {
position: fixed; position: fixed;
-68
View File
@@ -1,68 +0,0 @@
// Dokument-Ansicht: Wikilink-Verlinkung im DOM und Editor-Flush.
import { describe, expect, it, vi } from "vitest";
import { initPanelView, linkWikiRefs } from "./panel-view";
describe("linkWikiRefs", () => {
it("verlinkt [[ziel]] und [[ziel|Label]], Code bleibt unangetastet", () => {
const root = document.createElement("div");
root.innerHTML =
"<p>Siehe [[adr-logging]] und [[notiz|die Notiz]].</p>" +
"<pre><code>if [[ -f x ]]; then</code></pre>";
const onClick = vi.fn();
linkWikiRefs(root, onClick);
const links = [...root.querySelectorAll<HTMLElement>("a.wiki")];
expect(links.map((a) => a.textContent)).toEqual(["adr-logging", "die Notiz"]);
links[1].click();
expect(onClick).toHaveBeenCalledWith("notiz");
expect(root.querySelector("code")!.textContent).toBe("if [[ -f x ]]; then");
});
});
function viewSetup(onCommit: (text: string) => void | Promise<void>) {
document.body.innerHTML = `
<div id="content"></div>
<button id="copy"></button>
<button id="mode"></button>
<button id="edit"></button>`;
const view = initPanelView({
content: document.getElementById("content")!,
copyBtn: document.getElementById("copy")!,
modeBtn: document.getElementById("mode")!,
editContentBtn: document.getElementById("edit")!,
onCommit,
});
const editor = document.querySelector<HTMLTextAreaElement>(".panel-editor")!;
return { view, editor, editBtn: document.getElementById("edit")! };
}
describe("initPanelView — Editor", () => {
it("flush speichert eine offene Bearbeitung und beendet sie", async () => {
const onCommit = vi.fn();
const { view, editor, editBtn } = viewSetup(onCommit);
view.set("Alt");
editBtn.click();
expect(editor.hidden).toBe(false);
editor.value = "Neu";
await view.flush();
expect(onCommit).toHaveBeenCalledWith("Neu");
expect(editor.hidden).toBe(true);
});
it("flush ohne offene Bearbeitung tut nichts", async () => {
const onCommit = vi.fn();
const { view } = viewSetup(onCommit);
view.set("Alt");
await view.flush();
expect(onCommit).not.toHaveBeenCalled();
});
it("puffert eingehende Updates während der Bearbeitung", () => {
const { view, editor, editBtn } = viewSetup(() => {});
view.set("Alt");
editBtn.click();
view.set("Update von außen");
expect(editBtn.classList.contains("changed")).toBe(true);
editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(view.raw()).toBe("Update von außen");
});
});
-270
View File
@@ -1,270 +0,0 @@
import { renderMarkdown } from "./markdown";
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.
/// Der Rohtext bleibt die Quelle für „Kopieren"; die Ansicht rendert Markdown.
/// Entfernt Inline-Markdown (Emphasis, Code, Links), damit der Plain-Text-Titel
/// nicht die rohen Marker zeigt.
function stripInlineMd(s: string): string {
return s
.replace(/`([^`]*)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/_([^_]+)_/g, "$1")
.replace(/~~([^~]+)~~/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.trim();
}
/// Titel für den Panel-Kopf: erste Überschrift (# …) oder sonst erste
/// nicht-leere Zeile, ohne Inline-Markdown; „Dokument" bei leerem Text.
function firstLine(text: string): string {
let fallback = "";
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
const h = t.replace(/^#+/, "").trim();
if (t.startsWith("#") && h) return stripInlineMd(h);
if (!fallback) fallback = t;
}
return fallback ? stripInlineMd(fallback) : "Dokument";
}
/// Ersetzt die erste Überschrift (bzw. legt eine an) im Rohtext durch `title`.
function setHeading(text: string, title: string): string {
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t) continue;
if (t.startsWith("#")) {
const hashes = (t.match(/^#+/) as RegExpMatchArray)[0];
lines[i] = `${hashes} ${title}`;
} else {
lines.splice(i, 0, `# ${title}`, "");
}
return lines.join("\n");
}
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;
modeBtn: HTMLElement;
titleEl?: HTMLElement;
editBtn?: HTMLElement;
editContentBtn?: HTMLElement;
langSelect?: HTMLSelectElement;
/// Standard-Sprache der Rechtschreibprüfung (aus den App-Settings).
defaultLang?: string;
/// Wird mit dem neuen Rohtext aufgerufen, wenn Titel oder Inhalt geändert
/// wurden.
onCommit?: (text: string) => void | Promise<void>;
/// Klick auf einen `[[name]]`-Wikilink im gerenderten Markdown.
onWikiLink?: (name: string) => void;
}): PanelView {
let rawText = "";
let rendered = true;
// Content-Edit-Zustand: während des Editierens werden eingehende Updates
// gepuffert statt angewandt.
let editing = false;
let pending: string | null = null;
let flushEditor: () => Promise<void> = async () => {};
function draw() {
if (rendered) {
opts.content.className = "md";
opts.content.innerHTML = renderMarkdown(rawText);
if (opts.onWikiLink) linkWikiRefs(opts.content, opts.onWikiLink);
} else {
opts.content.className = "raw";
opts.content.textContent = rawText;
}
opts.modeBtn.textContent = rendered ? "MD" : "Roh";
}
opts.modeBtn.addEventListener("click", () => {
rendered = !rendered;
draw();
});
opts.copyBtn.addEventListener("click", async () => {
await writeText(rawText);
flash(opts.copyBtn, "copied");
});
// Titel-Edit: Edit-Button macht den Titel editierbar, Enter/Blur schreibt die
// geänderte Überschrift zurück, Escape verwirft.
const titleEl = opts.titleEl;
if (opts.editBtn && titleEl && opts.onCommit) {
const commit = () => {
if (titleEl.getAttribute("contenteditable") !== "true") return;
titleEl.removeAttribute("contenteditable");
const nt = (titleEl.textContent || "").trim();
if (nt && nt !== firstLine(rawText)) opts.onCommit!(setHeading(rawText, nt));
else titleEl.textContent = firstLine(rawText);
};
opts.editBtn.addEventListener("click", () => {
titleEl.setAttribute("contenteditable", "true");
titleEl.focus();
const r = document.createRange();
r.selectNodeContents(titleEl);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(r);
});
titleEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
titleEl.blur();
} else if (e.key === "Escape") {
titleEl.textContent = firstLine(rawText);
titleEl.removeAttribute("contenteditable");
titleEl.blur();
}
});
titleEl.addEventListener("blur", commit);
}
// Content-Edit: Button schaltet den Inhalt auf eine Rohtext-Textarea. Speichern
// (Button erneut oder Cmd/Ctrl+Enter) schreibt zurück, Escape verwirft.
// Eingehende Updates während des Editierens werden gepuffert.
if (opts.editContentBtn && opts.onCommit) {
const editBtn = opts.editContentBtn;
const editor = document.createElement("textarea");
editor.className = "panel-editor";
editor.spellcheck = true;
editor.hidden = true;
opts.content.after(editor);
// Sprache der Rechtschreibprüfung: Default aus den Settings, per Selector
// pro Text überschreibbar.
let lang = opts.defaultLang || "de";
const sel = opts.langSelect;
if (sel) {
if ([...sel.options].some((o) => o.value === lang)) sel.value = lang;
else lang = sel.value;
sel.addEventListener("change", () => {
lang = sel.value;
editor.lang = lang;
// Neuprüfung erzwingen.
editor.spellcheck = false;
editor.spellcheck = true;
if (!editor.hidden) editor.focus();
});
}
editor.lang = lang;
const leave = () => {
editing = false;
editor.hidden = true;
opts.content.hidden = false;
editBtn.classList.remove("active", "changed");
};
const enter = () => {
editing = true;
pending = null;
editor.value = rawText;
opts.content.hidden = true;
editor.hidden = false;
editBtn.classList.add("active");
editor.focus();
};
flushEditor = async () => {
if (!editing) return;
const v = editor.value;
leave();
await opts.onCommit!(v); // schreibt Datei -> panel-update -> set() rendert
};
const save = () => void flushEditor();
const cancel = () => {
const p = pending;
leave();
if (p !== null) applyText(p);
else draw();
};
editBtn.addEventListener("click", () => (editing ? save() : enter()));
editor.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
});
}
function applyText(text: string) {
rawText = text;
if (titleEl && titleEl.getAttribute("contenteditable") !== "true") {
titleEl.textContent = firstLine(text);
}
draw();
}
draw();
return {
set(text: string) {
// Während einer Inhalts-Bearbeitung nicht überschreiben — puffern und den
// Edit-Button als „geändert" markieren.
if (editing) {
pending = text;
opts.editContentBtn?.classList.add("changed");
return;
}
applyText(text);
},
raw: () => rawText,
flush: () => flushEditor(),
};
}
+245
View File
@@ -0,0 +1,245 @@
/* Theme-Variablen — Defaults sind Mocha; applyTheme (themes.ts) überschreibt. */
:root {
--bg: #1e1e2e;
--surface: #181825;
--tile: #11111b;
--line: #313244;
--line-strong: #45475a;
--text: #cdd6f4;
--muted: #a6adc8;
--faint: #6c7086;
--accent: #89b4fa;
--ok: #a6e3a1;
--warn: #f9e2af;
--err: #f38ba8;
}
html,
body {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
}
/* Feiner Fensterrahmen fürs dekorationslose Fenster (nur Linux). */
:root[data-platform="other"] body {
box-sizing: border-box;
border: 1px solid var(--line);
}
.panel-head {
flex: none;
display: flex;
flex-direction: column;
gap: 3px;
padding: 7px 8px 9px 14px;
background: var(--surface);
border-bottom: 1px solid var(--line);
font:
500 12px/1 -apple-system,
system-ui,
sans-serif;
user-select: none;
-webkit-user-select: none;
}
.panel-titlerow {
display: flex;
align-items: center;
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: var(--surface);
border-bottom: 1px solid var(--line);
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: var(--line);
}
.winbtn:hover svg {
opacity: 1;
}
.winbtn.close:hover {
background: var(--err);
color: var(--tile);
}
/* 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: var(--line-strong);
margin: 0 6px;
align-self: center;
}
.panel-title {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
}
.panel-btn {
border: none;
background: transparent;
color: var(--muted);
cursor: default;
border-radius: 6px;
padding: 6px 9px;
font: inherit;
display: flex;
align-items: center;
}
.panel-btn:hover {
background: var(--line);
color: var(--text);
}
.panel-btn.copied {
color: var(--ok);
}
.panel-btn.error {
color: var(--err);
}
.panel-btn svg {
display: block;
stroke: currentColor;
stroke-width: 1.3;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.panel-btn.active {
background: var(--line);
color: var(--text);
}
/* Aktiver Tab: Akzentfarbe + Unterstrich statt nur Flächenwechsel. */
.panel-tabs .panel-btn.active {
background: transparent;
color: var(--accent);
border-radius: 0;
box-shadow: inset 0 -2px 0 currentColor;
}
.panel-btn.changed {
color: var(--warn);
}
.panel-tabs {
display: flex;
gap: 2px;
}
.panel-btn[hidden] {
display: none;
}
+72 -129
View File
@@ -1,29 +1,21 @@
/// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und /// Gemeinsame Panel-Verdrahtung für das angedockte Panel (terminal.ts) und
/// das abgelöste Fenster (panel.ts): Entwurfs-Ansicht, Befehls- und /// das abgelöste Fenster (panel.ts): alle Tabs kommen aus der Modul-Registry
/// Such-Kacheln, Modus-Umschalter, Wikilink-Klick und die drei Update-Events. /// (src/modules) — Tab-Knöpfe und Content-Container entstehen hier aus der
/// Die Element-IDs sind in beiden HTML-Dateien identisch; die Draft-Controls /// Liste der aktiven Module, Erstbefüllung per buffer_read, Updates über
/// trägt das Markup als `.draft-only`. /// `<buffer>-update`-Events.
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { initPanelView, type PanelView } from "./panel-view"; import { initPanelMode, type ModeTab, type PanelMode } from "./commands-view";
import { import { panelToast } from "./tiles";
initCommandsView, import { PANEL_TABS, type ModuleCtx, type ModuleView } from "./modules";
initPanelMode, import { t } from "./messages";
panelToast,
type CommandsView,
type PanelMode,
} from "./commands-view";
import { initSearchView } from "./search-view";
import { initWikiView } from "./wiki-view";
import "./panel-tiles.css"; import "./panel-tiles.css";
export interface PanelWiring { export interface PanelWiring {
view: PanelView; /// Modul-Ansichten nach Tab-Modus; enthält nur Tabs aktiver Module.
cmdView: CommandsView; views: Map<string, ModuleView>;
mode: { to(m: PanelMode): void; clear(): void; current(): PanelMode | null }; mode: { to(m: PanelMode): void; clear(): void; current(): PanelMode | null };
/// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung).
draft: string;
} }
export async function wirePanel( export async function wirePanel(
@@ -31,123 +23,74 @@ export async function wirePanel(
onIncoming?: () => void, onIncoming?: () => void,
): Promise<PanelWiring> { ): Promise<PanelWiring> {
const titleEl = document.querySelector(".panel-title") as HTMLElement; const titleEl = document.querySelector(".panel-title") as HTMLElement;
const [defaultLang, draft, cmds, search, wiki, archiveHome] = await Promise.all([ const enabled = await invoke<string[]>("enabled_modules", { project });
invoke<string>("spellcheck_lang"), const tabs = PANEL_TABS.filter((tab) => enabled.includes(tab.module));
invoke<string>("panel_read", { project }),
invoke<string>("commands_read", { project }),
invoke<string>("search_read", { project }),
invoke<string>("wiki_read", { project }),
invoke<string | null>("panel_archive_dir_cmd", { project }),
]);
// Ohne Archiv (in den Projekt-Einstellungen abgewählt) gibt es nur Befehle const ctx: ModuleCtx = { project, toast: panelToast };
// und Dokument — Wiki-/Suche-Tabs und die Archiv-Werkzeuge verschwinden.
if (!archiveHome) { // Tab-Leiste und Container aus der Registry. Die Container-IDs
for (const sel of [ // (`<mode>-content`) sind zugleich die CSS-Anker der Ansichten; die
'#panel-tabs [data-mode="wiki"]', // Container reihen sich hinter der Kopfzeile (.panel-head) ein.
'#panel-tabs [data-mode="search"]', const tabsEl = document.getElementById("panel-tabs")!;
"#panel-archive", const views = new Map<string, ModuleView>();
"#panel-wiki-jump", const modeTabs: ModeTab[] = [];
]) { let anchor = document.querySelector(".panel-head") as HTMLElement;
const el = document.querySelector<HTMLElement>(sel)!; for (const tab of tabs) {
el.hidden = true; const btn = document.createElement("button");
// Raus aus der draft-only-Menge, sonst blendet der Modus-Umschalter btn.className = "panel-btn";
// die Archiv-Werkzeuge im Dokument-Modus wieder ein. btn.dataset.mode = tab.mode;
el.classList.remove("draft-only"); btn.textContent = t(tab.labelKey);
btn.title = t(tab.titleKey);
tabsEl.append(btn);
if (tab.sepAfter) {
const sep = document.createElement("span");
sep.className = "tab-sep";
tabsEl.append(sep);
} }
const content = document.createElement("div");
content.id = `${tab.mode}-content`;
content.hidden = true;
anchor.after(content);
anchor = content;
views.set(tab.mode, tab.init(content, ctx));
modeTabs.push({
mode: tab.mode,
btn,
content,
label: t(tab.labelKey),
onActivate: tab.onActivate
? () => tab.onActivate!(views.get(tab.mode)!, ctx)
: undefined,
});
}
// Archiv-Zugang: fester Knopf hinter den Tabs — öffnet das Trilium-Fenster
// des Projekts (kein Modus, kein Content-Container).
if (enabled.includes("trilium")) {
const btn = document.createElement("button");
btn.className = "panel-btn";
btn.textContent = t("panel.archiveOpen");
btn.title = t("panel.archiveOpenTitle");
btn.addEventListener("click", () =>
void invoke("trilium_open", { project, noteId: null }).catch((e) =>
panelToast(String(e)),
),
);
tabsEl.append(btn);
} }
// Jeder Wiki-Sprung (Wikilink, Chip, Dokument-Sprung) geht als ein Invoke an const mode = initPanelMode({ tabs: modeTabs, titleEl });
// den Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück.
// Fehler (z. B. Ziel nicht im Archiv) erscheinen als Toast.
const openWiki = (name: string) =>
invoke("wiki_open", { project, name }).catch((e) => panelToast(String(e)));
const view = initPanelView({ await Promise.all(
content: document.getElementById("panel-content")!, tabs.map(async (tab) => {
copyBtn: document.getElementById("panel-copy")!, const v = views.get(tab.mode)!;
modeBtn: document.getElementById("panel-mode")!, v.set(await invoke<string>("buffer_read", { project, buffer: tab.buffer }));
titleEl, await listen<string>(`${tab.buffer}-update`, (e) => {
editBtn: document.getElementById("panel-title-edit")!, v.set(e.payload);
editContentBtn: document.getElementById("panel-content-edit")!, mode.to(tab.mode);
langSelect: document.getElementById("panel-lang") as HTMLSelectElement, onIncoming?.();
defaultLang,
onCommit: (text) => invoke("panel_set", { project, text }),
onWikiLink: openWiki,
}); });
const cmdView = initCommandsView( }),
document.getElementById("commands-content")!,
(id) => invoke("commands_delete", { project, id }),
); );
// Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und
// archivierbar); der Sprung ins Wiki geht von dort aus.
const searchView = initSearchView(
document.getElementById("search-content")!,
(path) =>
void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))),
(raw) => {
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
const words = raw.split(/\s+/).filter(Boolean);
const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null;
const query = words.filter((w) => !w.startsWith("#")).join(" ");
void invoke("search_run", { project, query, tag }).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); return { views, mode };
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:");
});
// Sprung Dokument → Wiki: löst den angezeigten Titel gegen das Archiv auf.
document
.getElementById("panel-wiki-jump")!
.addEventListener("click", () => void openWiki(titleEl.textContent || ""));
await listen<string>("panel-update", (e) => {
// Erst umschalten, dann setzen: to("draft") restauriert den gemerkten
// Titel — der neue Inhalt (und damit sein Titel) muss danach kommen.
mode.to("draft");
view.set(e.payload);
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 };
} }
+17 -38
View File
@@ -1,3 +1,4 @@
import "./panel-window.css";
import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css"; import "@fontsource/jetbrains-mono/500.css";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
@@ -5,13 +6,12 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi"; import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { wirePanel } from "./panel-wiring"; import { wirePanel } from "./panel-wiring";
import { flash, panelToast } from "./commands-view";
import { initArchiveForm } from "./archive-form";
import { applyTheme, THEMES } from "./themes"; import { applyTheme, THEMES } from "./themes";
import { applyI18n } from "./messages";
// Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt // Abgelöstes Panel-Fenster: dieselben Modul-Tabs wie das angedockte Panel,
// danach denselben Update-Events wie das angedockte Panel; startet in // gespeist aus denselben Puffer-Update-Events.
// „Befehle", wenn es (noch) keinen Entwurf gibt. applyI18n();
const project = new URLSearchParams(location.search).get("project")!; const project = new URLSearchParams(location.search).get("project")!;
const win = getCurrentWebviewWindow(); const win = getCurrentWebviewWindow();
@@ -96,40 +96,19 @@ applyTheme(picked);
document.documentElement.style.background = picked.header; document.documentElement.style.background = picked.header;
document.body.style.background = picked.header; document.body.style.background = picked.header;
const { view, cmdView, mode, draft } = await wirePanel(project); const { views, mode } = await wirePanel(project);
if (!draft.trim() && !cmdView.empty()) mode.to("commands"); // Beim Ablösen kommt der dort aktive Tab als URL-Parameter mit; ohne ihn
// (Kaltstart): ToDo-Liste, falls gewählt und nicht leer, sonst der erste Tab.
// Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken const initialMode = new URLSearchParams(location.search).get("mode");
// wählt notfalls erst das Archiv-Home per Dialog. const todos = views.get("todo");
const archiveBtn = document.getElementById("panel-archive")!; const first = views.keys().next().value;
const archiveForm = initArchiveForm(archiveBtn, async (meta) => { if (initialMode) {
try { mode.to(initialMode);
const configured = await invoke<string | null>("panel_archive_dir_cmd", { } else if (todos && !todos.empty()) {
project, mode.to("todo");
}); } else if (first) {
let dir: string | undefined; mode.to(first);
if (!configured) {
const { open } = await import("@tauri-apps/plugin-dialog");
const chosen = await open({ directory: true, title: "Archiv-Ordner wählen" });
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,
folder: meta.folder ?? null,
description: meta.description ?? null,
tags: meta.tags,
});
flash(archiveBtn, "copied", 1400);
invoke("reveal_path_cmd", { path });
} catch (e) {
flash(archiveBtn, "error", 1400);
panelToast(`Archivieren fehlgeschlagen: ${e}`);
}
});
archiveBtn.addEventListener("click", () => archiveForm.toggle());
// „Andocken": angedocktes Panel wieder einblenden, dann dieses Fenster schließen. // „Andocken": angedocktes Panel wieder einblenden, dann dieses Fenster schließen.
document.getElementById("panel-dock")!.addEventListener("click", async () => { document.getElementById("panel-dock")!.addEventListener("click", async () => {
+5
View File
@@ -0,0 +1,5 @@
html,
body {
margin: 0;
background: transparent;
}
+3 -1
View File
@@ -1,4 +1,6 @@
import "./popup-window.css";
import { createApp } from "vue"; import { createApp } from "vue";
import Popup from "./components/Popup.vue"; import Popup from "./components/Popup.vue";
import { i18n } from "./i18n";
createApp(Popup).mount("#app"); createApp(Popup).use(i18n).mount("#app");
+4 -6
View File
@@ -20,8 +20,7 @@ function setup() {
const run = JSON.stringify({ const run = JSON.stringify({
query: "arch", query: "arch",
tag: null, tag: null,
home: "/tmp/archiv", hits: [{ note_id: "abc123def", title: "X" }],
hits: [{ relpath: "a/2026-01-01_0000-x.md", title: "X", snippet: "ein **arch**iv" }],
}); });
describe("initSearchView", () => { describe("initSearchView", () => {
@@ -74,19 +73,18 @@ describe("initSearchView", () => {
expect(onSearch).toHaveBeenCalledWith("panel arch"); expect(onSearch).toHaveBeenCalledWith("panel arch");
}); });
it("rendert Treffer; Klick liefert den absoluten Pfad", () => { it("rendert Treffer; Klick liefert die noteId", () => {
const { view, onOpen } = setup(); const { view, onOpen } = setup();
view.set(run); view.set(run);
const tile = document.querySelector<HTMLElement>(".hit-tile")!; const tile = document.querySelector<HTMLElement>(".hit-tile")!;
expect(tile.querySelector(".hit-title")!.textContent).toBe("X"); expect(tile.querySelector(".hit-title")!.textContent).toBe("X");
expect(tile.querySelectorAll("mark").length).toBe(1);
tile.click(); tile.click();
expect(onOpen).toHaveBeenCalledWith("/tmp/archiv/a/2026-01-01_0000-x.md"); expect(onOpen).toHaveBeenCalledWith("abc123def");
}); });
it("zeigt „Keine Treffer“ bei leerem Ergebnis", () => { it("zeigt „Keine Treffer“ bei leerem Ergebnis", () => {
const { view } = setup(); const { view } = setup();
view.set(JSON.stringify({ query: "nix", tag: "adr", home: "/a", hits: [] })); view.set(JSON.stringify({ query: "nix", tag: "adr", hits: [] }));
expect(document.querySelector(".hit-head")!.textContent).toBe( expect(document.querySelector(".hit-head")!.textContent).toBe(
"Keine Treffer für „nix“ · #adr", "Keine Treffer für „nix“ · #adr",
); );

Some files were not shown because too many files have changed in this diff Show More