Compare commits
30 Commits
a1c95dae4a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47951a615b | |||
| f58b9887c7 | |||
| 1523856ea7 | |||
| 513e1a768f | |||
| f7a99b1176 | |||
| 8e95332552 | |||
| 704255e471 | |||
| 257b2ca2ef | |||
| f13186e667 | |||
| ea08563411 | |||
| 0faa1e48fd | |||
| a6dd567c0a | |||
| 4bc4174d24 | |||
| df828daea9 | |||
| 6b4afd2bc5 | |||
| e7f98dd072 | |||
| bbb06e0814 | |||
| 1fa9ffcf1b | |||
| 92f34cb64d | |||
| e15e128fd3 | |||
| e6226cfc9f | |||
| cf2c25df79 | |||
| 347fc70982 | |||
| 6c765ff7b5 | |||
| 6682040cc0 | |||
| ab1c561d6c | |||
| 446f52ca6d | |||
| 8068471120 | |||
| b743de5f34 | |||
| 23f6562f50 |
@@ -1,30 +1,58 @@
|
||||
# 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).
|
||||
|
||||
<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:
|
||||
|
||||
- **Pools** — named credential sets (OAuth login or API key) under `~/.config/ai-control/pools/<pool>`. Each pool is a self-contained Claude config directory with its own keychain entry.
|
||||
- **Projects** — arbitrary directories, mapped through a registry (`~/.config/ai-control/projects.json`, name → path; paths under home are stored as `~/…` and therefore stable across machines). Each project is assigned a pool, plus terminal settings (theme, icon, window title).
|
||||
- **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 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.
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
- **`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`).
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `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.
|
||||
|
||||

|
||||
|
||||
| Command tiles | Archive search |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
The interface speaks English and German across all windows; the language follows the browser locale and can be switched in the app.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -40,30 +68,76 @@ The panel docks into the terminal window and can be detached into its own window
|
||||
|
||||
### 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
|
||||
xattr -d com.apple.quarantine /Applications/ai-control.app # or
|
||||
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.
|
||||
|
||||
### 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.
|
||||
- **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. 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).
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
@@ -73,33 +147,35 @@ Follows soon.
|
||||
├── panel.html detached text panel window
|
||||
├── popup.html tray popup
|
||||
├── src/ frontend: Vue 3 + TypeScript
|
||||
│ (incl. wiki-view, search-view, archive-form,
|
||||
│ commands-view + vitest tests)
|
||||
├── src-tauri/
|
||||
│ ├── src/lib.rs entry point, process roles
|
||||
│ ├── src/app.rs Tauri wiring: tray, main/popup windows, watcher
|
||||
│ ├── src/mcp.rs MCP stdio server (write_panel / archive_panel)
|
||||
│ ├── src/mcp.rs MCP stdio server (6 tools, see above)
|
||||
│ ├── src/terminal.rs PTY sessions (portable-pty), terminal windows
|
||||
│ ├── src/domain/ registry, project config, settings, watcher,
|
||||
│ │ deletion scopes
|
||||
│ ├── src/platform/ macOS / Linux / Windows specifics
|
||||
│ ├── capabilities/ per-window ACL (main/popup/terminal/panel)
|
||||
│ └── icons/ app and tray icons
|
||||
├── gnome-shell-extension/ tray/popup integration for GNOME
|
||||
├── cinnamon-extension/ popup placement for Cinnamon Wayland
|
||||
├── kwin-script/ popup placement for KDE Wayland
|
||||
├── 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:
|
||||
|
||||
- **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.
|
||||
The three process roles that binary takes are described under [Architecture](#architecture).
|
||||
|
||||
## Tooling
|
||||
|
||||
| Area | Stack |
|
||||
|-----------|-------|
|
||||
| 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 |
|
||||
| macOS | objc2 / objc2-app-kit (Dock icon, window focus, tray) |
|
||||
| Linux | GTK 3 / WebKitGTK, ksni (StatusNotifierItem), zbus (D-Bus) |
|
||||
@@ -108,13 +184,17 @@ Running projects are detected through their terminal processes (`pgrep` for `--t
|
||||
## Development
|
||||
|
||||
```sh
|
||||
./dev.sh # tauri dev with hot reload
|
||||
./build.sh # macOS release build; bundle lands in src-tauri/target/release/bundle/macos.noindex/
|
||||
./dev.sh # tauri dev with hot reload
|
||||
./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.
|
||||
|
||||
@@ -123,7 +203,7 @@ Prerequisites: Rust (stable), Node.js/npm. `dev.sh`/`build.sh` expect `CARGO_HOM
|
||||
```
|
||||
~/.config/ai-control/
|
||||
├── settings.json app settings (see below)
|
||||
├── projects.json project registry: project id → directory
|
||||
├── projects.json registry: project UUID → directory
|
||||
│ + pool assignment (machine-local)
|
||||
└── pools/<pool>/ one Claude config directory per pool
|
||||
└── 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
|
||||
├── .claude/ Claude Code project configuration
|
||||
└── .ai-control/ syncs with the project
|
||||
├── config.json project id (UUID) + display name,
|
||||
│ terminal settings, panel archive
|
||||
│ directory (archiveHome)
|
||||
├── config.json project id (UUID), display name,
|
||||
│ terminal settings (theme, icon, title),
|
||||
│ archive home (archiveHome)
|
||||
└── 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. |
|
||||
| `poolSyncDir` | unset | Directory to sync pool runtime data into (session transcripts, todos, prompt history — what `/resume` needs). When set, a pool's runtime files are replaced by symlinks into `<poolSyncDir>/<pool>`, so sessions travel with however that directory is synced (git, Syncthing, …). Unset: everything stays local in the pool directory. |
|
||||
| `terminalFontSize` | `13` | Font size of the built-in terminal (also adjustable from the terminal window). |
|
||||
| `spellcheckLang` | unset | Default spell-check language of the text panel (per-text override in the panel). |
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -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
@@ -1,15 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# macOS-Deploy nach build.sh: Dev-Bundle aus Launch Services austragen und in
|
||||
# ein .noindex-Verzeichnis schieben — Spotlight indiziert .noindex nicht, damit
|
||||
# erscheint im Starter/Spotlight nur die installierte Kopie. Installiert wird
|
||||
# über das DMG (Pfad am Ende).
|
||||
# macOS-Deploy nach build.sh: das frisch gebaute Bundle nach ~/Applications
|
||||
# installieren. Das Dev-Bundle wandert danach aus Launch Services heraus in ein
|
||||
# .noindex-Verzeichnis — Spotlight indiziert .noindex nicht, damit erscheint im
|
||||
# Starter/Spotlight nur die installierte Kopie.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
BUNDLE="$DIR/src-tauri/target/release/bundle/macos"
|
||||
NOINDEX="$DIR/src-tauri/target/release/bundle/macos.noindex"
|
||||
TARGET="$HOME/Applications"
|
||||
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 —
|
||||
# erwartetes Ergebnis, kein Fehler.
|
||||
"$LSREGISTER" -u "$BUNDLE/ai-control.app" || true
|
||||
@@ -18,4 +40,5 @@ mkdir -p "$NOINDEX"
|
||||
mv "$BUNDLE/ai-control.app" "$NOINDEX/"
|
||||
"$LSREGISTER" -u "$NOINDEX/ai-control.app" || true
|
||||
|
||||
echo "installiert: $TARGET/ai-control.app"
|
||||
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
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ai-control</title>
|
||||
<title>aICentral</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ai-control",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+9
-411
@@ -2,433 +2,31 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Dokument</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>
|
||||
<title data-i18n="panel.windowPanel">Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel-topbar" data-tauri-drag-region>
|
||||
<div class="panel-tabs" id="panel-tabs">
|
||||
<button class="panel-btn" data-mode="commands" title="Befehls-History">Befehle</button>
|
||||
<span class="tab-sep"></span>
|
||||
<button class="panel-btn" data-mode="draft" title="Dokument">Dokument</button>
|
||||
<button class="panel-btn" data-mode="wiki" title="Archiv-Wiki">Wiki</button>
|
||||
<button class="panel-btn" data-mode="search" title="Suchtreffer">Suche</button>
|
||||
<span class="tab-sep"></span>
|
||||
</div>
|
||||
<button class="winbtn" id="panel-dock" title="Wieder andocken">
|
||||
<!-- Tabs kommen aus der Modul-Registry (panel-wiring). -->
|
||||
<div class="panel-tabs" id="panel-tabs"></div>
|
||||
<button class="winbtn" id="panel-dock" data-i18n-title="panel.dock">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5" /><path d="M9.5 2.5v11" /><path d="M4 8h3.4M6 6.2 7.8 8 6 9.8" /></svg>
|
||||
</button>
|
||||
<button class="winbtn osctl" id="win-min" aria-label="Minimieren">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Modul-Container (<mode>-content) reihen sich hinter der Kopfzeile ein. -->
|
||||
<div class="panel-head">
|
||||
<div class="panel-titlerow" data-tauri-drag-region>
|
||||
<span class="panel-title">Dokument</span>
|
||||
<button class="panel-btn panel-edit draft-only" id="panel-title-edit" title="Titel bearbeiten">✎</button>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<select class="panel-lang 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>
|
||||
<span class="panel-title"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="panel-content" class="md"></div>
|
||||
<div id="commands-content" hidden></div>
|
||||
<div id="search-content" hidden></div>
|
||||
<div id="wiki-content" hidden></div>
|
||||
<div id="resize-grips" aria-hidden="true">
|
||||
<div class="grip n" data-dir="North"></div>
|
||||
<div class="grip s" data-dir="South"></div>
|
||||
|
||||
+1
-8
@@ -2,14 +2,7 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Projekte</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
<title data-i18n="panel.windowProjects">Projekte</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+27
-76
@@ -19,18 +19,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -42,7 +30,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ai-control"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"glib",
|
||||
@@ -54,8 +42,8 @@ dependencies = [
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"portable-pty",
|
||||
"pulldown-cmark",
|
||||
"rfd 0.15.4",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -1256,18 +1244,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -1803,16 +1779,7 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash 0.7.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1830,15 +1797,6 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -2367,17 +2325,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -3172,6 +3119,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
@@ -3465,20 +3430,6 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.42.1"
|
||||
@@ -4930,6 +4881,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -5009,12 +4966,6 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ai-control"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Pool- und Session-Verwaltung für Claude Code"
|
||||
authors = ["marcus.hinz"]
|
||||
license = "MIT"
|
||||
@@ -34,8 +34,8 @@ base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
portable-pty = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# Volltext-Suche übers Panel-Archiv (SQLite FTS5, in-memory aus dem Baum gebaut).
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
# Markdown→HTML beim Archivieren in Trilium (Notizen sind HTML).
|
||||
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
|
||||
keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
@@ -46,6 +46,9 @@ rfd = { version = "0.15", default-features = false, features = ["gtk3"] }
|
||||
ksni = "0.1"
|
||||
|
||||
[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-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder", "NSRunningApplication"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSString"] }
|
||||
|
||||
+65
-1
@@ -1,3 +1,67 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
// App-ACL-Manifest: erzeugt pro Command allow-/deny-Permissions. Sobald das
|
||||
// Manifest existiert, gilt deny-by-default — jedes Command muss in einer
|
||||
// Capability einem Fenster zugeteilt sein (capabilities/*.json), sonst
|
||||
// lehnt Tauri den Aufruf ab. Neue Commands hier UND in der passenden
|
||||
// Capability eintragen.
|
||||
tauri_build::try_build(
|
||||
tauri_build::Attributes::new().app_manifest(tauri_build::AppManifest::new().commands(&[
|
||||
"list_projects",
|
||||
"create_project_full",
|
||||
"add_project",
|
||||
"delete_preview",
|
||||
"delete_project_scoped",
|
||||
"project_work_dirs",
|
||||
"set_project_dir",
|
||||
"add_work_dir",
|
||||
"remove_work_dir",
|
||||
"list_pools",
|
||||
"create_oauth_pool",
|
||||
"create_apikey_pool",
|
||||
"create_reference_pool",
|
||||
"default_config_dir",
|
||||
"rename_pool",
|
||||
"delete_pool",
|
||||
"assign_pool",
|
||||
"unassign_pool",
|
||||
"set_terminal_config",
|
||||
"project_icon",
|
||||
"pool_label",
|
||||
"usage_stats",
|
||||
"stop_project",
|
||||
"restart_project",
|
||||
"start_or_focus_cmd",
|
||||
"open_main_window",
|
||||
"quit_app",
|
||||
"sync_setting",
|
||||
"set_sync_setting",
|
||||
"terminal_font_size",
|
||||
"set_terminal_font_size",
|
||||
"link_pool_runtime",
|
||||
"oauth_login",
|
||||
"keychain_status",
|
||||
"set_apikey",
|
||||
"trilium_status",
|
||||
"trilium_setup",
|
||||
"trilium_connect",
|
||||
"trilium_open",
|
||||
"trilium_disable",
|
||||
"open_terminal",
|
||||
"term_start",
|
||||
"term_log",
|
||||
"term_write",
|
||||
"term_resize",
|
||||
"buffer_read",
|
||||
"enabled_modules",
|
||||
"module_registry",
|
||||
"set_module",
|
||||
"commands_delete",
|
||||
"todos_delete",
|
||||
"todos_add",
|
||||
"todos_update",
|
||||
"search_run",
|
||||
"open_panel_window",
|
||||
])),
|
||||
)
|
||||
.expect("tauri-build");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "Hauptfenster: komplette Projekt-/Pool-Verwaltung.",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"autostart:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"allow-list-projects",
|
||||
"allow-create-project-full",
|
||||
"allow-add-project",
|
||||
"allow-project-work-dirs",
|
||||
"allow-set-project-dir",
|
||||
"allow-add-work-dir",
|
||||
"allow-remove-work-dir",
|
||||
"allow-list-pools",
|
||||
"allow-create-oauth-pool",
|
||||
"allow-create-apikey-pool",
|
||||
"allow-create-reference-pool",
|
||||
"allow-default-config-dir",
|
||||
"allow-rename-pool",
|
||||
"allow-delete-pool",
|
||||
"allow-assign-pool",
|
||||
"allow-unassign-pool",
|
||||
"allow-set-terminal-config",
|
||||
"allow-project-icon",
|
||||
"allow-usage-stats",
|
||||
"allow-stop-project",
|
||||
"allow-restart-project",
|
||||
"allow-open-terminal",
|
||||
"allow-sync-setting",
|
||||
"allow-set-sync-setting",
|
||||
"allow-link-pool-runtime",
|
||||
"allow-oauth-login",
|
||||
"allow-keychain-status",
|
||||
"allow-set-apikey",
|
||||
"allow-delete-preview",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "panel",
|
||||
"description": "Abgelöstes Panel-Fenster: nur Panel-/Archiv-Kanäle, keine PTY, keine Verwaltung.",
|
||||
"windows": [
|
||||
"panel-*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"autostart:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"allow-buffer-read",
|
||||
"allow-commands-delete",
|
||||
"allow-enabled-modules",
|
||||
"allow-list-projects",
|
||||
"allow-trilium-open",
|
||||
"allow-search-run",
|
||||
"allow-term-log",
|
||||
"allow-todos-add",
|
||||
"allow-todos-delete",
|
||||
"allow-todos-update"
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"identifier": "popup",
|
||||
"description": "Tray-Popup: Projektliste anzeigen und starten.",
|
||||
"windows": [
|
||||
"main",
|
||||
"popup",
|
||||
"term-*",
|
||||
"panel-*"
|
||||
"popup"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
@@ -22,6 +19,11 @@
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"autostart:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text"
|
||||
"clipboard-manager:allow-write-text",
|
||||
"allow-list-projects",
|
||||
"allow-project-icon",
|
||||
"allow-start-or-focus-cmd",
|
||||
"allow-open-main-window",
|
||||
"allow-quit-app"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "terminal",
|
||||
"description": "Terminal-Fenster: PTY und angedocktes Panel.",
|
||||
"windows": [
|
||||
"term-*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"autostart:default",
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"allow-buffer-read",
|
||||
"allow-commands-delete",
|
||||
"allow-enabled-modules",
|
||||
"allow-list-projects",
|
||||
"allow-open-panel-window",
|
||||
"allow-trilium-open",
|
||||
"allow-pool-label",
|
||||
"allow-project-icon",
|
||||
"allow-search-run",
|
||||
"allow-set-terminal-font-size",
|
||||
"allow-term-log",
|
||||
"allow-term-resize",
|
||||
"allow-term-start",
|
||||
"allow-term-write",
|
||||
"allow-terminal-font-size",
|
||||
"allow-todos-add",
|
||||
"allow-todos-delete",
|
||||
"allow-todos-update"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-add-project"
|
||||
description = "Enables the add_project command without any pre-configured scope."
|
||||
commands.allow = ["add_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-add-project"
|
||||
description = "Denies the add_project command without any pre-configured scope."
|
||||
commands.deny = ["add_project"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-add-work-dir"
|
||||
description = "Enables the add_work_dir command without any pre-configured scope."
|
||||
commands.allow = ["add_work_dir"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-add-work-dir"
|
||||
description = "Denies the add_work_dir command without any pre-configured scope."
|
||||
commands.deny = ["add_work_dir"]
|
||||
@@ -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"]
|
||||
@@ -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-assign-pool"
|
||||
description = "Enables the assign_pool command without any pre-configured scope."
|
||||
commands.allow = ["assign_pool"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-assign-pool"
|
||||
description = "Denies the assign_pool command without any pre-configured scope."
|
||||
commands.deny = ["assign_pool"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-commands-delete"
|
||||
description = "Enables the commands_delete command without any pre-configured scope."
|
||||
commands.allow = ["commands_delete"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-commands-delete"
|
||||
description = "Denies the commands_delete command without any pre-configured scope."
|
||||
commands.deny = ["commands_delete"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-commands-read"
|
||||
description = "Enables the commands_read command without any pre-configured scope."
|
||||
commands.allow = ["commands_read"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-commands-read"
|
||||
description = "Denies the commands_read command without any pre-configured scope."
|
||||
commands.deny = ["commands_read"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-create-apikey-pool"
|
||||
description = "Enables the create_apikey_pool command without any pre-configured scope."
|
||||
commands.allow = ["create_apikey_pool"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-create-apikey-pool"
|
||||
description = "Denies the create_apikey_pool command without any pre-configured scope."
|
||||
commands.deny = ["create_apikey_pool"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-create-oauth-pool"
|
||||
description = "Enables the create_oauth_pool command without any pre-configured scope."
|
||||
commands.allow = ["create_oauth_pool"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-create-oauth-pool"
|
||||
description = "Denies the create_oauth_pool command without any pre-configured scope."
|
||||
commands.deny = ["create_oauth_pool"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-create-project-full"
|
||||
description = "Enables the create_project_full command without any pre-configured scope."
|
||||
commands.allow = ["create_project_full"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-create-project-full"
|
||||
description = "Denies the create_project_full command without any pre-configured scope."
|
||||
commands.deny = ["create_project_full"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-delete-pool"
|
||||
description = "Enables the delete_pool command without any pre-configured scope."
|
||||
commands.allow = ["delete_pool"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-delete-pool"
|
||||
description = "Denies the delete_pool command without any pre-configured scope."
|
||||
commands.deny = ["delete_pool"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-delete-preview"
|
||||
description = "Enables the delete_preview command without any pre-configured scope."
|
||||
commands.allow = ["delete_preview"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-delete-preview"
|
||||
description = "Denies the delete_preview command without any pre-configured scope."
|
||||
commands.deny = ["delete_preview"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-delete-project-scoped"
|
||||
description = "Enables the delete_project_scoped command without any pre-configured scope."
|
||||
commands.allow = ["delete_project_scoped"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-delete-project-scoped"
|
||||
description = "Denies the delete_project_scoped command without any pre-configured scope."
|
||||
commands.deny = ["delete_project_scoped"]
|
||||
@@ -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-keychain-status"
|
||||
description = "Enables the keychain_status command without any pre-configured scope."
|
||||
commands.allow = ["keychain_status"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-keychain-status"
|
||||
description = "Denies the keychain_status command without any pre-configured scope."
|
||||
commands.deny = ["keychain_status"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-link-pool-runtime"
|
||||
description = "Enables the link_pool_runtime command without any pre-configured scope."
|
||||
commands.allow = ["link_pool_runtime"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-link-pool-runtime"
|
||||
description = "Denies the link_pool_runtime command without any pre-configured scope."
|
||||
commands.deny = ["link_pool_runtime"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-list-pools"
|
||||
description = "Enables the list_pools command without any pre-configured scope."
|
||||
commands.allow = ["list_pools"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-list-pools"
|
||||
description = "Denies the list_pools command without any pre-configured scope."
|
||||
commands.deny = ["list_pools"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-list-projects"
|
||||
description = "Enables the list_projects command without any pre-configured scope."
|
||||
commands.allow = ["list_projects"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-list-projects"
|
||||
description = "Denies the list_projects command without any pre-configured scope."
|
||||
commands.deny = ["list_projects"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-oauth-login"
|
||||
description = "Enables the oauth_login command without any pre-configured scope."
|
||||
commands.allow = ["oauth_login"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-oauth-login"
|
||||
description = "Denies the oauth_login command without any pre-configured scope."
|
||||
commands.deny = ["oauth_login"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-open-main-window"
|
||||
description = "Enables the open_main_window command without any pre-configured scope."
|
||||
commands.allow = ["open_main_window"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-open-main-window"
|
||||
description = "Denies the open_main_window command without any pre-configured scope."
|
||||
commands.deny = ["open_main_window"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-open-panel-window"
|
||||
description = "Enables the open_panel_window command without any pre-configured scope."
|
||||
commands.allow = ["open_panel_window"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-open-panel-window"
|
||||
description = "Denies the open_panel_window command without any pre-configured scope."
|
||||
commands.deny = ["open_panel_window"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-open-terminal"
|
||||
description = "Enables the open_terminal command without any pre-configured scope."
|
||||
commands.allow = ["open_terminal"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-open-terminal"
|
||||
description = "Denies the open_terminal command without any pre-configured scope."
|
||||
commands.deny = ["open_terminal"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-panel-read"
|
||||
description = "Enables the panel_read command without any pre-configured scope."
|
||||
commands.allow = ["panel_read"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-panel-read"
|
||||
description = "Denies the panel_read command without any pre-configured scope."
|
||||
commands.deny = ["panel_read"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-pool-label"
|
||||
description = "Enables the pool_label command without any pre-configured scope."
|
||||
commands.allow = ["pool_label"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-pool-label"
|
||||
description = "Denies the pool_label command without any pre-configured scope."
|
||||
commands.deny = ["pool_label"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-project-icon"
|
||||
description = "Enables the project_icon command without any pre-configured scope."
|
||||
commands.allow = ["project_icon"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-project-icon"
|
||||
description = "Denies the project_icon command without any pre-configured scope."
|
||||
commands.deny = ["project_icon"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-project-work-dirs"
|
||||
description = "Enables the project_work_dirs command without any pre-configured scope."
|
||||
commands.allow = ["project_work_dirs"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-project-work-dirs"
|
||||
description = "Denies the project_work_dirs command without any pre-configured scope."
|
||||
commands.deny = ["project_work_dirs"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-quit-app"
|
||||
description = "Enables the quit_app command without any pre-configured scope."
|
||||
commands.allow = ["quit_app"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-quit-app"
|
||||
description = "Denies the quit_app command without any pre-configured scope."
|
||||
commands.deny = ["quit_app"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-remove-work-dir"
|
||||
description = "Enables the remove_work_dir command without any pre-configured scope."
|
||||
commands.allow = ["remove_work_dir"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-remove-work-dir"
|
||||
description = "Denies the remove_work_dir command without any pre-configured scope."
|
||||
commands.deny = ["remove_work_dir"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-rename-pool"
|
||||
description = "Enables the rename_pool command without any pre-configured scope."
|
||||
commands.allow = ["rename_pool"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-rename-pool"
|
||||
description = "Denies the rename_pool command without any pre-configured scope."
|
||||
commands.deny = ["rename_pool"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-restart-project"
|
||||
description = "Enables the restart_project command without any pre-configured scope."
|
||||
commands.allow = ["restart_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-restart-project"
|
||||
description = "Denies the restart_project command without any pre-configured scope."
|
||||
commands.deny = ["restart_project"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-search-read"
|
||||
description = "Enables the search_read command without any pre-configured scope."
|
||||
commands.allow = ["search_read"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-search-read"
|
||||
description = "Denies the search_read command without any pre-configured scope."
|
||||
commands.deny = ["search_read"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-search-run"
|
||||
description = "Enables the search_run command without any pre-configured scope."
|
||||
commands.allow = ["search_run"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-search-run"
|
||||
description = "Denies the search_run command without any pre-configured scope."
|
||||
commands.deny = ["search_run"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-set-apikey"
|
||||
description = "Enables the set_apikey command without any pre-configured scope."
|
||||
commands.allow = ["set_apikey"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-set-apikey"
|
||||
description = "Denies the set_apikey command without any pre-configured scope."
|
||||
commands.deny = ["set_apikey"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-set-project-dir"
|
||||
description = "Enables the set_project_dir command without any pre-configured scope."
|
||||
commands.allow = ["set_project_dir"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-set-project-dir"
|
||||
description = "Denies the set_project_dir command without any pre-configured scope."
|
||||
commands.deny = ["set_project_dir"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-set-sync-setting"
|
||||
description = "Enables the set_sync_setting command without any pre-configured scope."
|
||||
commands.allow = ["set_sync_setting"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-set-sync-setting"
|
||||
description = "Denies the set_sync_setting command without any pre-configured scope."
|
||||
commands.deny = ["set_sync_setting"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-set-terminal-config"
|
||||
description = "Enables the set_terminal_config command without any pre-configured scope."
|
||||
commands.allow = ["set_terminal_config"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-set-terminal-config"
|
||||
description = "Denies the set_terminal_config command without any pre-configured scope."
|
||||
commands.deny = ["set_terminal_config"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-set-terminal-font-size"
|
||||
description = "Enables the set_terminal_font_size command without any pre-configured scope."
|
||||
commands.allow = ["set_terminal_font_size"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-set-terminal-font-size"
|
||||
description = "Denies the set_terminal_font_size command without any pre-configured scope."
|
||||
commands.deny = ["set_terminal_font_size"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-set-todo"
|
||||
description = "Enables the set_todo command without any pre-configured scope."
|
||||
commands.allow = ["set_todo"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-set-todo"
|
||||
description = "Denies the set_todo command without any pre-configured scope."
|
||||
commands.deny = ["set_todo"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-start-or-focus-cmd"
|
||||
description = "Enables the start_or_focus_cmd command without any pre-configured scope."
|
||||
commands.allow = ["start_or_focus_cmd"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-start-or-focus-cmd"
|
||||
description = "Denies the start_or_focus_cmd command without any pre-configured scope."
|
||||
commands.deny = ["start_or_focus_cmd"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-stop-project"
|
||||
description = "Enables the stop_project command without any pre-configured scope."
|
||||
commands.allow = ["stop_project"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-stop-project"
|
||||
description = "Denies the stop_project command without any pre-configured scope."
|
||||
commands.deny = ["stop_project"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-sync-setting"
|
||||
description = "Enables the sync_setting command without any pre-configured scope."
|
||||
commands.allow = ["sync_setting"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-sync-setting"
|
||||
description = "Denies the sync_setting command without any pre-configured scope."
|
||||
commands.deny = ["sync_setting"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-term-log"
|
||||
description = "Enables the term_log command without any pre-configured scope."
|
||||
commands.allow = ["term_log"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-term-log"
|
||||
description = "Denies the term_log command without any pre-configured scope."
|
||||
commands.deny = ["term_log"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-term-resize"
|
||||
description = "Enables the term_resize command without any pre-configured scope."
|
||||
commands.allow = ["term_resize"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-term-resize"
|
||||
description = "Denies the term_resize command without any pre-configured scope."
|
||||
commands.deny = ["term_resize"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-term-start"
|
||||
description = "Enables the term_start command without any pre-configured scope."
|
||||
commands.allow = ["term_start"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-term-start"
|
||||
description = "Denies the term_start command without any pre-configured scope."
|
||||
commands.deny = ["term_start"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-term-write"
|
||||
description = "Enables the term_write command without any pre-configured scope."
|
||||
commands.allow = ["term_write"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-term-write"
|
||||
description = "Denies the term_write command without any pre-configured scope."
|
||||
commands.deny = ["term_write"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-terminal-font-size"
|
||||
description = "Enables the terminal_font_size command without any pre-configured scope."
|
||||
commands.allow = ["terminal_font_size"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-terminal-font-size"
|
||||
description = "Denies the terminal_font_size command without any pre-configured scope."
|
||||
commands.deny = ["terminal_font_size"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-todo-state"
|
||||
description = "Enables the todo_state command without any pre-configured scope."
|
||||
commands.allow = ["todo_state"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-todo-state"
|
||||
description = "Denies the todo_state command without any pre-configured scope."
|
||||
commands.deny = ["todo_state"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-unassign-pool"
|
||||
description = "Enables the unassign_pool command without any pre-configured scope."
|
||||
commands.allow = ["unassign_pool"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-unassign-pool"
|
||||
description = "Denies the unassign_pool command without any pre-configured scope."
|
||||
commands.deny = ["unassign_pool"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-usage-stats"
|
||||
description = "Enables the usage_stats command without any pre-configured scope."
|
||||
commands.allow = ["usage_stats"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-usage-stats"
|
||||
description = "Denies the usage_stats command without any pre-configured scope."
|
||||
commands.deny = ["usage_stats"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-wiki-read"
|
||||
description = "Enables the wiki_read command without any pre-configured scope."
|
||||
commands.allow = ["wiki_read"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-wiki-read"
|
||||
description = "Denies the wiki_read command without any pre-configured scope."
|
||||
commands.deny = ["wiki_read"]
|
||||
+31
-26
@@ -61,19 +61,26 @@ pub(crate) fn show_popup(app: &tauri::AppHandle, anchor: Anchor) {
|
||||
};
|
||||
let _ = w.set_position(tauri::PhysicalPosition::new(x, y));
|
||||
}
|
||||
// SNI (KDE/XFCE/Cinnamon): Activate liefert keine brauchbaren
|
||||
// Koordinaten, aber der Zeiger sitzt beim Klick auf dem Icon.
|
||||
Anchor::Cursor => {
|
||||
// SNI (KDE/XFCE/Cinnamon): Activate-Koordinaten, wenn der Host welche
|
||||
// mitgibt (Cinnamon); sonst Zeigerposition — der Zeiger sitzt beim
|
||||
// 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")]
|
||||
{
|
||||
// Gelöst wird die Sperre vom ersten Focused(true) im Event-Handler.
|
||||
if let Some(g) = win.try_state::<PopupBlurGuard>() {
|
||||
g.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
match win.cursor_position() {
|
||||
Ok(p) => position_popup(&w, p.x as i32, p.y as i32),
|
||||
Err(_) => {
|
||||
let _ = w.center();
|
||||
if (x, y) != (0, 0) {
|
||||
position_popup(&w, x, y);
|
||||
} else {
|
||||
match win.cursor_position() {
|
||||
Ok(p) => position_popup(&w, p.x as i32, p.y as i32),
|
||||
Err(_) => {
|
||||
let _ = w.center();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
|
||||
let main_win = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
|
||||
.title("ai-control")
|
||||
.title("aICentral")
|
||||
.inner_size(800.0, 600.0)
|
||||
// Fenster-Icon (_NET_WM_ICON) — deckt X11-DEs wie XFCE direkt ab,
|
||||
// unabhängig von der .desktop-Zuordnung; auf Windows das Titel-/Taskbar-Icon.
|
||||
@@ -247,14 +254,16 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
||||
commands::list_projects,
|
||||
commands::create_project_full,
|
||||
commands::add_project,
|
||||
commands::remove_project,
|
||||
commands::delete_project,
|
||||
commands::delete_preview,
|
||||
commands::delete_project_scoped,
|
||||
commands::project_work_dirs,
|
||||
commands::set_project_dir,
|
||||
commands::add_work_dir,
|
||||
commands::remove_work_dir,
|
||||
commands::list_pools,
|
||||
commands::create_oauth_pool,
|
||||
commands::create_reference_pool,
|
||||
commands::default_config_dir,
|
||||
commands::create_apikey_pool,
|
||||
commands::rename_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::project_icon,
|
||||
commands::pool_label,
|
||||
commands::todo_state,
|
||||
commands::set_todo,
|
||||
commands::usage_stats,
|
||||
commands::stop_project,
|
||||
commands::restart_project,
|
||||
@@ -279,27 +286,25 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
||||
commands::oauth_login,
|
||||
commands::keychain_status,
|
||||
commands::set_apikey,
|
||||
commands::panel_archive_dir_cmd,
|
||||
commands::panel_archive_cmd,
|
||||
commands::set_archive_home_cmd,
|
||||
commands::clear_archive_home_cmd,
|
||||
commands::archive_docs_cmd,
|
||||
commands::reveal_path_cmd,
|
||||
commands::spellcheck_lang,
|
||||
commands::trilium_status,
|
||||
commands::trilium_setup,
|
||||
commands::trilium_connect,
|
||||
commands::trilium_open,
|
||||
commands::trilium_disable,
|
||||
commands::enabled_modules,
|
||||
commands::module_registry,
|
||||
commands::set_module,
|
||||
terminal::open_terminal,
|
||||
terminal::term_start,
|
||||
terminal::term_log,
|
||||
terminal::term_write,
|
||||
terminal::term_resize,
|
||||
terminal::panel_read,
|
||||
terminal::commands_read,
|
||||
terminal::buffer_read,
|
||||
terminal::commands_delete,
|
||||
terminal::panel_set,
|
||||
terminal::search_read,
|
||||
terminal::todos_delete,
|
||||
terminal::todos_add,
|
||||
terminal::todos_update,
|
||||
terminal::search_run,
|
||||
terminal::panel_load,
|
||||
terminal::wiki_read,
|
||||
terminal::wiki_open,
|
||||
terminal::open_panel_window
|
||||
]
|
||||
}
|
||||
|
||||
+170
-83
@@ -3,24 +3,21 @@
|
||||
|
||||
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::paths::Paths;
|
||||
use crate::domain::pool::{
|
||||
create_apikey_pool_in, create_oauth_pool_in, delete_pool_in, ensure_oauth_pool,
|
||||
link_pool_runtime_in, list_pools_in, read_pool, rename_pool_in, set_apikey_in, PoolInfo,
|
||||
create_apikey_pool_in, create_oauth_pool_in, create_reference_pool_in, delete_pool_in,
|
||||
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::{
|
||||
add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_project_in,
|
||||
display_name_in, is_running, kill_terminals, list_projects_in, project_work_dirs_in,
|
||||
read_project_config_in, remove_work_dir_in, resolve_icon_path, running_projects_using_pool,
|
||||
set_project_dir_in, set_terminal_config_in, unassign_pool_in, Project, TerminalConfig,
|
||||
add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_preview_in,
|
||||
delete_project_scoped_in, display_name_in, is_running, kill_terminals, list_projects_in,
|
||||
project_work_dirs_in, read_project_config_in, remove_work_dir_in, resolve_icon_path,
|
||||
running_projects_using_pool, set_project_dir_in, set_terminal_config_in, unassign_pool_in,
|
||||
DeletePreview, Project, TerminalConfig,
|
||||
};
|
||||
use crate::domain::registry::unregister_project;
|
||||
use crate::domain::settings;
|
||||
use crate::domain::todo::{set_todo_in, todo_state_in};
|
||||
use crate::domain::usage::{usage_stats_in, UsageRow};
|
||||
use crate::platform::KeychainStore;
|
||||
use crate::terminal;
|
||||
@@ -40,7 +37,6 @@ pub(crate) fn create_project_full(
|
||||
work_dir: Option<String>,
|
||||
create_work_dir: bool,
|
||||
terminal: TerminalConfig,
|
||||
todo: bool,
|
||||
) -> Result<String, String> {
|
||||
create_project_full_in(
|
||||
&Paths::real(),
|
||||
@@ -50,7 +46,6 @@ pub(crate) fn create_project_full(
|
||||
work_dir.as_deref(),
|
||||
create_work_dir,
|
||||
terminal,
|
||||
todo,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,23 +55,19 @@ pub(crate) fn add_project(path: String) -> Result<(), String> {
|
||||
add_project_in(&Paths::real(), &path)
|
||||
}
|
||||
|
||||
/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
|
||||
#[tauri::command]
|
||||
pub(crate) fn remove_project(project: String) -> Result<(), String> {
|
||||
if is_running(&project) {
|
||||
let name = display_name_in(&Paths::real(), &project)?;
|
||||
/// 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"));
|
||||
}
|
||||
unregister_project(&Paths::real(), &project)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Projektordner neu zuordnen; bei laufender Session gesperrt.
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> {
|
||||
if is_running(&project) {
|
||||
let name = display_name_in(&Paths::real(), &project)?;
|
||||
return Err(format!("{name} läuft noch — erst beenden"));
|
||||
}
|
||||
ensure_not_running(&project)?;
|
||||
set_project_dir_in(&Paths::real(), &project, &dir)
|
||||
}
|
||||
|
||||
@@ -90,13 +81,23 @@ pub(crate) fn remove_work_dir(project: String, dir: String) -> Result<(), String
|
||||
remove_work_dir_in(&Paths::real(), &project, &dir)
|
||||
}
|
||||
|
||||
/// Artefakt-Vorschau für den Lösch-Dialog.
|
||||
#[tauri::command]
|
||||
pub(crate) fn delete_project(project: String, delete_work_dirs: bool) -> Result<(), String> {
|
||||
if is_running(&project) {
|
||||
let name = display_name_in(&Paths::real(), &project)?;
|
||||
return Err(format!("{name} läuft noch — erst beenden"));
|
||||
}
|
||||
delete_project_in(&Paths::real(), &project, delete_work_dirs)
|
||||
pub(crate) fn delete_preview(project: String) -> Result<DeletePreview, String> {
|
||||
delete_preview_in(&Paths::real(), &project)
|
||||
}
|
||||
|
||||
/// Löschen in drei Stufen: "integration" (nur ai-control-Spuren),
|
||||
/// "archive" (zusätzlich Archiv), "full" (zusätzlich Projektordner,
|
||||
/// Arbeitsordner per Flag).
|
||||
#[tauri::command]
|
||||
pub(crate) fn delete_project_scoped(
|
||||
project: String,
|
||||
scope: String,
|
||||
delete_work_dirs: bool,
|
||||
) -> Result<(), String> {
|
||||
ensure_not_running(&project)?;
|
||||
delete_project_scoped_in(&Paths::real(), &project, &scope, delete_work_dirs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -104,16 +105,6 @@ pub(crate) fn project_work_dirs(project: String) -> Result<Vec<String>, String>
|
||||
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]
|
||||
pub(crate) fn set_terminal_config(
|
||||
project: String,
|
||||
@@ -212,7 +203,21 @@ pub(crate) fn set_apikey(pool: String, key: String, allow_file: bool) -> Result<
|
||||
|
||||
#[tauri::command]
|
||||
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,
|
||||
@@ -222,6 +227,13 @@ pub(crate) fn keychain_status(pool: String) -> Result<bool, String> {
|
||||
pub(crate) fn oauth_login(pool: String) -> Result<(), String> {
|
||||
let paths = Paths::real();
|
||||
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)?;
|
||||
if !running.is_empty() {
|
||||
@@ -231,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)? {
|
||||
crate::platform::oauth_keychain_delete(&service)?;
|
||||
}
|
||||
@@ -335,11 +347,6 @@ pub(crate) fn set_terminal_font_size(size: u32) -> Result<(), String> {
|
||||
settings::set_terminal_font_size_in(&Paths::real(), size)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn spellcheck_lang() -> String {
|
||||
settings::spellcheck_lang(&Paths::real())
|
||||
}
|
||||
|
||||
// ---------- Verbrauch ----------
|
||||
|
||||
#[tauri::command]
|
||||
@@ -347,59 +354,139 @@ pub(crate) fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
|
||||
usage_stats_in(&Paths::real(), days)
|
||||
}
|
||||
|
||||
// ---------- Panel-Archiv ----------
|
||||
|
||||
/// Konfiguriertes Archiv-Home des Projekts (für den Panel-Button: entscheidet,
|
||||
/// ob der Ordner-Dialog nötig ist).
|
||||
/// Modul-Registry für den Settings-Dialog (Checkbox-Gruppe „Module").
|
||||
#[tauri::command]
|
||||
pub(crate) fn panel_archive_dir_cmd(project: String) -> Option<String> {
|
||||
project_archive_home(&project).map(|p| p.display().to_string())
|
||||
pub(crate) fn module_registry(project: String) -> Result<Vec<crate::domain::modules::ModuleInfo>, String> {
|
||||
crate::domain::modules::module_infos_in(&Paths::real(), &project)
|
||||
}
|
||||
|
||||
/// Setzt das Archiv-Home eines Projekts (Einstellungsdialog) — inklusive
|
||||
/// Permissions-Eintrag in der Projekt-settings.json.
|
||||
/// Schaltet ein Modul im Projekt an/ab (Settings-Dialog, schreibt sofort).
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_archive_home_cmd(project: String, dir: String) -> Result<(), String> {
|
||||
crate::domain::archive::set_project_archive_home(&project, &dir)
|
||||
pub(crate) fn set_module(project: String, module: String, enabled: bool) -> Result<(), String> {
|
||||
crate::domain::modules::set_module_in(&Paths::real(), &project, &module, enabled)
|
||||
}
|
||||
|
||||
/// Wählt das Archiv ab (Einstellungsdialog): Config-Eintrag und Permissions
|
||||
/// weg, der Ordner bleibt liegen.
|
||||
/// Aktive Module fürs Frontend (Registry-Enablement der Projekt-Config).
|
||||
#[tauri::command]
|
||||
pub(crate) fn clear_archive_home_cmd(project: String) -> Result<(), String> {
|
||||
crate::domain::archive::clear_project_archive_home(&project)
|
||||
pub(crate) fn enabled_modules(project: String) -> Result<Vec<String>, String> {
|
||||
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]
|
||||
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,
|
||||
dir: Option<String>,
|
||||
folder: Option<String>,
|
||||
description: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<String, String> {
|
||||
let meta = ArchiveMeta {
|
||||
folder: folder.filter(|f| !f.trim().is_empty()),
|
||||
description: description.filter(|d| !d.trim().is_empty()),
|
||||
tags: tags.unwrap_or_default(),
|
||||
};
|
||||
archive_panel_content(&project, dir.as_deref(), &meta).map(|p| p.display().to_string())
|
||||
password: String,
|
||||
) -> Result<TriliumStatus, String> {
|
||||
let paths = Paths::real();
|
||||
crate::domain::trilium::ensure_instance(&paths, &project)?;
|
||||
if !crate::domain::trilium::initialized(&project)? {
|
||||
crate::domain::trilium::setup_new_document(&project)?;
|
||||
crate::domain::trilium::set_password(&project, &password)?;
|
||||
}
|
||||
let token = crate::domain::trilium::login(&project, &password)?;
|
||||
crate::domain::trilium::store_token(&project, &token)?;
|
||||
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]
|
||||
pub(crate) fn archive_docs_cmd(
|
||||
pub(crate) async fn trilium_open(
|
||||
app: tauri::AppHandle,
|
||||
project: String,
|
||||
) -> Result<Vec<crate::domain::archive_index::Doc>, String> {
|
||||
let home = require_archive_home(&project)?;
|
||||
crate::domain::archive_index::scan_archive(&home)
|
||||
note_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
crate::domain::trilium::ensure_instance(&Paths::real(), &project)?;
|
||||
let mut url = crate::domain::trilium::base_url(&project);
|
||||
if let Some(id) = ¬e_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]
|
||||
pub(crate) fn reveal_path_cmd(path: String) {
|
||||
crate::platform::reveal_path(std::path::Path::new(&path));
|
||||
pub(crate) async fn trilium_disable(project: String) -> Result<(), String> {
|
||||
crate::domain::modules::set_module_in(&Paths::real(), &project, "trilium", false)?;
|
||||
crate::domain::trilium::stop_instance(&project);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,468 +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())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ArchiveMeta {
|
||||
/// Unterordner relativ zum Archiv-Home (wird angelegt).
|
||||
pub(crate) folder: Option<String>,
|
||||
/// Einzeiler fürs Frontmatter.
|
||||
pub(crate) description: Option<String>,
|
||||
/// Schlagwörter fürs Frontmatter.
|
||||
pub(crate) tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit
|
||||
/// Frontmatter im Archiv-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 = free_path(&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());
|
||||
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Freier Dateiname für das Archiv-Dokument.
|
||||
///
|
||||
/// Der Zeitstempel hat Minutenauflösung; zweimal Archivieren innerhalb einer
|
||||
/// Minute mit derselben Titelzeile ergäbe denselben Namen. Da mit `fs::write`
|
||||
/// geschrieben wird, wäre das stiller Datenverlust — ausgerechnet im Archiv,
|
||||
/// das die dauerhafte Ablage ist. Darum bei Kollision `-2`, `-3`, … anhängen.
|
||||
fn free_path(dir: &std::path::Path, stamp: &str, slug: &str) -> PathBuf {
|
||||
let first = dir.join(format!("{stamp}-{slug}.md"));
|
||||
if !first.exists() {
|
||||
return first;
|
||||
}
|
||||
(2..)
|
||||
.map(|n| dir.join(format!("{stamp}-{slug}-{n}.md")))
|
||||
.find(|p| !p.exists())
|
||||
.expect("unendlicher Zahlenraum")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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 = free_path(&dir, "2026-07-19_2118", "notiz");
|
||||
assert_eq!(erste.file_name().unwrap(), "2026-07-19_2118-notiz.md");
|
||||
fs::write(&erste, "alt").unwrap();
|
||||
|
||||
let zweite = free_path(&dir, "2026-07-19_2118", "notiz");
|
||||
assert_eq!(zweite.file_name().unwrap(), "2026-07-19_2118-notiz-2.md");
|
||||
fs::write(&zweite, "neu").unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&erste).unwrap(), "alt");
|
||||
let dritte = free_path(&dir, "2026-07-19_2118", "notiz");
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,15 +23,28 @@ pub(crate) trait ApikeyStore {
|
||||
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
|
||||
/// über den Pool-Pfad.
|
||||
pub(crate) fn keychain_service(paths: &Paths, pool: &str) -> String {
|
||||
let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes());
|
||||
/// über den Verzeichnispfad. Claudes Default-Verzeichnis läuft ohne gesetzte
|
||||
/// Variable und trägt deshalb den unsuffixierten Eintrag — ein Pool, der
|
||||
/// 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();
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -43,11 +56,22 @@ mod tests {
|
||||
fn keychain_service_suffix() {
|
||||
let p = Paths { home: PathBuf::from("/Users/marcus.hinz") };
|
||||
assert_eq!(
|
||||
keychain_service(&p, "privateDefault"),
|
||||
keychain_service_for(&p, &p.pool_dir("privateDefault")),
|
||||
"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]
|
||||
fn apikey_helper_kette_referenz() {
|
||||
let dir = PathBuf::from("/pools/abc");
|
||||
|
||||
@@ -2,22 +2,94 @@
|
||||
//! (injizierbares Home) und den `ApikeyStore`-Trait testbar; OS-Aufrufe
|
||||
//! 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 modules;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod trilium;
|
||||
pub(crate) mod pool;
|
||||
pub(crate) mod project;
|
||||
pub(crate) mod registry;
|
||||
pub(crate) mod settings;
|
||||
pub(crate) mod todo;
|
||||
pub(crate) mod usage;
|
||||
pub(crate) mod watcher;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testutil;
|
||||
|
||||
/// Datei atomar ersetzen: temp-Datei im Zielverzeichnis, dann rename. Ein
|
||||
/// Absturz oder eine volle Platte hinterlässt nie eine abgeschnittene
|
||||
/// Zieldatei — wichtig für Dateien, die der App nicht allein gehören
|
||||
/// (.claude/settings.json der Projekte, claudes .claude.json, Registry).
|
||||
pub(crate) fn write_atomic(path: &std::path::Path, content: &str) -> Result<(), String> {
|
||||
let dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("{}: kein Elternordner", path.display()))?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| format!("{}: kein Dateiname", path.display()))?;
|
||||
let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, content).map_err(|e| format!("{}: {e}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
format!("{}: {e}", path.display())
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Der Name wird als Pfadsegment verwendet (Pool-Verzeichnis, Panel-Dateien)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,13 @@ impl Paths {
|
||||
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,
|
||||
/// der Terminal-Prozess beobachtet die Datei und zeigt sie im Panel.
|
||||
fn panels_dir(&self) -> PathBuf {
|
||||
@@ -50,10 +57,21 @@ impl Paths {
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
|
||||
/// AI_CONTROL_PANEL in der PTY-Umgebung.
|
||||
pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||
Paths::real().panels_dir().join(format!("{project}.md"))
|
||||
/// Zweite Linie: Die Projekt-ID wird zum Dateinamen der Panel-Kanäle — ein
|
||||
/// Wert mit Pfad-Bestandteilen (etwa aus einer verfälschten Registry oder
|
||||
/// AI_CONTROL_PROJECT-Env) bricht hier laut ab, statt einen Pfad zu bilden.
|
||||
fn checked(project: &str) -> &str {
|
||||
crate::domain::check_name(project).unwrap();
|
||||
project
|
||||
}
|
||||
|
||||
/// Signal-Datei „Notiz öffnen": write_panel (MCP) schreibt die noteId der
|
||||
/// frisch angelegten Trilium-Notiz, die Fenster öffnen sie daraufhin. Der
|
||||
/// Pfad landet als AI_CONTROL_NOTEOPEN in der PTY-Umgebung.
|
||||
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
|
||||
@@ -62,7 +80,7 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||
pub(crate) fn commands_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{project}.commands.jsonl"))
|
||||
.join(format!("{}.commands.jsonl", checked(project)))
|
||||
}
|
||||
|
||||
/// Suchtreffer-Datei eines Projekts (JSON, letzter search_archive-Aufruf —
|
||||
@@ -71,16 +89,16 @@ pub(crate) fn commands_file(project: &str) -> PathBuf {
|
||||
pub(crate) fn search_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{project}.search.json"))
|
||||
.join(format!("{}.search.json", checked(project)))
|
||||
}
|
||||
|
||||
/// Wiki-Puffer eines Projekts (JSON, jeweils letzte Wiki-Seite bzw. letztes
|
||||
/// geöffnetes Dokument — flüchtig, wird beim Session-Start geleert). Der Pfad
|
||||
/// landet als AI_CONTROL_WIKI in der PTY-Umgebung.
|
||||
pub(crate) fn wiki_file(project: &str) -> PathBuf {
|
||||
/// Persistente ToDo-Liste eines Projekts (JSONL, anhängend — überlebt
|
||||
/// Sessions; write_todos hängt an, Kachel-Löschen entfernt Zeilen). Der Pfad
|
||||
/// landet als AI_CONTROL_TODOS in der PTY-Umgebung.
|
||||
pub(crate) fn todos_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{project}.wiki.json"))
|
||||
.join(format!("{}.todos.jsonl", checked(project)))
|
||||
}
|
||||
|
||||
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
|
||||
|
||||
+297
-97
@@ -22,6 +22,15 @@ pub(crate) struct Pool {
|
||||
pub(crate) name: String,
|
||||
#[serde(rename = "credentialType")]
|
||||
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
|
||||
/// ganze pool.json neu geschrieben (dieselbe Fehlerklasse wie ProjectConfig).
|
||||
#[serde(flatten)]
|
||||
pub(crate) rest: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -39,6 +48,35 @@ pub(crate) struct PoolInfo {
|
||||
pub(crate) running: Vec<String>,
|
||||
#[serde(rename = "hasCredentials")]
|
||||
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> {
|
||||
@@ -48,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()))
|
||||
}
|
||||
|
||||
/// 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(
|
||||
paths: &Paths,
|
||||
store: &dyn ApikeyStore,
|
||||
) -> Result<Vec<PoolInfo>, String> {
|
||||
let mut pools = Vec::new();
|
||||
if !paths.pools_dir().is_dir() {
|
||||
return Ok(pools);
|
||||
}
|
||||
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();
|
||||
for id in pool_ids(paths)? {
|
||||
let pool = read_pool(paths, &id)?;
|
||||
// oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen
|
||||
// Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true.
|
||||
// apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei.
|
||||
let has_credentials = match pool.credential_type.as_str() {
|
||||
"apikey" => {
|
||||
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())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -97,33 +140,19 @@ pub(crate) fn list_pools_in(
|
||||
name: pool.name,
|
||||
credential_type: pool.credential_type,
|
||||
has_credentials,
|
||||
dir: pool.dir,
|
||||
});
|
||||
}
|
||||
pools.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(pools)
|
||||
}
|
||||
|
||||
/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch
|
||||
/// nicht — dann ist die Liste leer.
|
||||
/// (ID, Anzeigename) aller Pools.
|
||||
pub(crate) fn pool_names(paths: &Paths) -> Result<Vec<(String, 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()))?;
|
||||
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)
|
||||
pool_ids(paths)?
|
||||
.into_iter()
|
||||
.map(|id| read_pool(paths, &id).map(|p| (id, p.name)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den
|
||||
@@ -136,15 +165,35 @@ fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
|
||||
Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string()))
|
||||
}
|
||||
|
||||
fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> 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()))?;
|
||||
let pool = Pool {
|
||||
name: name.to_string(),
|
||||
credential_type: credential_type.to_string(),
|
||||
};
|
||||
let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join(POOL_FILE), raw + "\n")
|
||||
.map_err(|e| format!("{}: {e}", dir.join(POOL_FILE).display()))
|
||||
let raw = serde_json::to_string_pretty(pool).map_err(|e| e.to_string())?;
|
||||
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
|
||||
@@ -169,8 +218,7 @@ pub(crate) fn init_pool_config(
|
||||
}
|
||||
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join("settings.json"), raw + "\n")
|
||||
.map_err(|e| format!("{}: {e}", dir.join("settings.json").display()))?;
|
||||
crate::domain::write_atomic(&dir.join("settings.json"), &(raw + "\n"))?;
|
||||
let claude_md = dir.join("CLAUDE.md");
|
||||
if !claude_md.is_file() {
|
||||
fs::write(&claude_md, "").map_err(|e| format!("{}: {e}", claude_md.display()))?;
|
||||
@@ -211,42 +259,20 @@ const STALE_PANEL_PERMISSIONS: [&str; 3] =
|
||||
|
||||
/// 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.
|
||||
/// 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) {
|
||||
let sp = pool_dir.join("settings.json");
|
||||
let Ok(raw) = fs::read_to_string(&sp) else {
|
||||
return;
|
||||
};
|
||||
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)));
|
||||
for perm in PANEL_PERMISSIONS {
|
||||
if !allow.iter().any(|e| e.as_str() == Some(perm)) {
|
||||
allow.push(serde_json::json!(perm));
|
||||
let _ = crate::domain::update_settings_permissions(&sp, false, |perms| {
|
||||
let allow = crate::domain::perm_array(perms, "allow")?;
|
||||
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
|
||||
for perm in PANEL_PERMISSIONS {
|
||||
if !allow.iter().any(|e| e.as_str() == Some(perm)) {
|
||||
allow.push(serde_json::json!(perm));
|
||||
}
|
||||
}
|
||||
}
|
||||
if *allow == before {
|
||||
return; // nichts geändert
|
||||
}
|
||||
if let Ok(out) = serde_json::to_string_pretty(&v) {
|
||||
let _ = fs::write(&sp, out + "\n");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
/// Registriert den MCP-Server (dieses Binary mit `--mcp-panel`) in der
|
||||
@@ -294,7 +320,7 @@ fn register_mcp_server(pool_dir: &std::path::Path) {
|
||||
servers.remove("aicontrol"); // alter Key vor Umbenennung
|
||||
servers.insert(PANEL_MCP_SERVER.into(), desired);
|
||||
if let Ok(out) = serde_json::to_string_pretty(&v) {
|
||||
let _ = fs::write(&cfg, out + "\n");
|
||||
let _ = crate::domain::write_atomic(&cfg, &(out + "\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,10 +333,14 @@ fn provision_pool(pool_dir: &std::path::Path) {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if let Ok(pools) = pool_names(paths) {
|
||||
for (id, _) in pools {
|
||||
provision_pool(&paths.pool_dir(&id));
|
||||
if let Ok(dir) = pool_config_dir(paths, &id) {
|
||||
provision_pool(&dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,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).
|
||||
pub(crate) fn link_pool_runtime_in(paths: &Paths, pool: &str) -> Result<(), String> {
|
||||
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 data = pool_data_dir(paths, pool)?;
|
||||
for (name, is_dir) in SYNCED_RUNTIME {
|
||||
@@ -383,11 +416,7 @@ pub(crate) fn create_apikey_pool_in(
|
||||
&dir,
|
||||
serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }),
|
||||
)?;
|
||||
write_pool_json(&dir, name, "apikey")?;
|
||||
if pool_sync_dir(paths).is_some() {
|
||||
link_pool_runtime_in(paths, &id)?;
|
||||
}
|
||||
Ok(id)
|
||||
finish_pool_create(paths, &dir, name, "apikey")
|
||||
}
|
||||
|
||||
/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
|
||||
@@ -397,38 +426,89 @@ pub(crate) fn create_apikey_pool_in(
|
||||
pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
|
||||
let dir = check_new_pool(paths, name)?;
|
||||
init_pool_config(&dir, serde_json::json!({}))?;
|
||||
write_pool_json(&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)?;
|
||||
finish_pool_create(paths, &dir, name, "oauth")
|
||||
}
|
||||
|
||||
/// 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()));
|
||||
}
|
||||
Ok(id)
|
||||
// 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
|
||||
/// (und damit Keychain-Suffix, Symlinks, Zuordnungen) bleiben unverändert.
|
||||
pub(crate) fn rename_pool_in(paths: &Paths, pool: &str, name: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let current = read_pool(paths, pool)?;
|
||||
let mut current = read_pool(paths, pool)?;
|
||||
if pool_names(paths)?.iter().any(|(id, n)| id != pool && n == name) {
|
||||
return Err(format!("Pool existiert bereits: {name}"));
|
||||
}
|
||||
write_pool_json(&paths.pool_dir(pool), name, ¤t.credential_type)
|
||||
current.name = name.to_string();
|
||||
write_pool_json(&paths.pool_dir(pool), ¤t)
|
||||
}
|
||||
|
||||
/// Löscht einen Pool samt Ordner (inkl. Credentials, bei apikey auch den
|
||||
/// Keychain-Eintrag). Zugeordnete Projekte verlieren die Zuordnung
|
||||
/// (Terminal-Einstellungen bleiben erhalten). Den Schutz gegen laufende
|
||||
/// 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(
|
||||
paths: &Paths,
|
||||
store: &dyn ApikeyStore,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
let dir = paths.pool_dir(name);
|
||||
if !dir.join(POOL_FILE).is_file() {
|
||||
return Err(format!("Pool nicht gefunden: {name}"));
|
||||
}
|
||||
// Fehlende pool.json scheitert hier — read_pool nennt den Pfad.
|
||||
if read_pool(paths, name)?.credential_type == "apikey" {
|
||||
store.delete(name)?;
|
||||
}
|
||||
@@ -477,7 +557,7 @@ pub(crate) fn set_apikey_in(
|
||||
settings["apiKeyHelper"] =
|
||||
serde_json::json!(crate::platform::apikey_helper_command(&dir, pool));
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(&settings_path, raw + "\n").map_err(|e| format!("{}: {e}", settings_path.display()))
|
||||
crate::domain::write_atomic(&settings_path, &(raw + "\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> {
|
||||
@@ -500,6 +580,126 @@ mod tests {
|
||||
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]
|
||||
fn apikey_pool_anlegen() {
|
||||
let p = tmp_paths();
|
||||
|
||||
+315
-165
@@ -38,6 +38,8 @@ pub(crate) struct Project {
|
||||
pub(crate) pool: Option<String>,
|
||||
pub(crate) running: bool,
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
/// Trilium-Modul aktiv — die Projektliste zeigt dann den Archiv-Knopf.
|
||||
pub(crate) archive: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
@@ -51,16 +53,11 @@ pub(crate) struct ProjectConfig {
|
||||
pub(crate) name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
/// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen
|
||||
/// (~-relativ gespeichert). Liest den früheren Key `archiveDir` weiterhin ein,
|
||||
/// geschrieben wird nur noch `archiveHome`.
|
||||
#[serde(
|
||||
default,
|
||||
rename = "archiveHome",
|
||||
alias = "archiveDir",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub(crate) archive_home: Option<String>,
|
||||
/// Modul-Abweichungen vom Default (`"commands": false`); fehlender Key =
|
||||
/// Default aus der Registry (domain/modules.rs). Kern-Module ignorieren
|
||||
/// den Eintrag.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub(crate) modules: std::collections::BTreeMap<String, bool>,
|
||||
/// Alle Keys, die dieser Build nicht kennt — unverändert durchgereicht.
|
||||
/// Ohne das verliert jeder read-modify-write (Pool-Zuweisung, Terminal-
|
||||
/// 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")
|
||||
}
|
||||
|
||||
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(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
) -> 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() {
|
||||
return Ok(ProjectConfig::default());
|
||||
}
|
||||
@@ -119,7 +119,7 @@ pub(crate) fn write_project_config_in(
|
||||
let dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = dir.join(PROJECT_FILE);
|
||||
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
|
||||
crate::domain::write_atomic(&path, &(raw + "\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn is_running(project: &str) -> bool {
|
||||
@@ -166,6 +166,7 @@ pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||||
path: contract_home(paths, &entry.dir),
|
||||
pool: entry.pool,
|
||||
name: cfg.name.unwrap_or_else(|| id.clone()),
|
||||
archive: cfg.modules.get("trilium").copied().unwrap_or(false),
|
||||
terminal: cfg.terminal,
|
||||
id,
|
||||
});
|
||||
@@ -218,7 +219,14 @@ pub(crate) fn project_pool_dir_in(
|
||||
return Ok(None);
|
||||
};
|
||||
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).
|
||||
@@ -231,7 +239,6 @@ pub(crate) fn project_config(project: &str) -> Result<ProjectConfig, String> {
|
||||
/// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit
|
||||
/// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter
|
||||
/// ~/claude-projects/<name>. Liefert die neue Projekt-ID.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn create_project_full_in(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
@@ -240,7 +247,6 @@ pub(crate) fn create_project_full_in(
|
||||
work_dir: Option<&str>,
|
||||
create_work_dir: bool,
|
||||
terminal: TerminalConfig,
|
||||
todo: bool,
|
||||
) -> Result<String, String> {
|
||||
check_name(name)?;
|
||||
let dir = match dir {
|
||||
@@ -292,8 +298,7 @@ pub(crate) fn create_project_full_in(
|
||||
},
|
||||
});
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join(".claude").join("settings.json"), raw + "\n")
|
||||
.map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?;
|
||||
crate::domain::write_atomic(&dir.join(".claude").join("settings.json"), &(raw + "\n"))?;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
reg.insert(id.clone(), RegEntry { dir: dir.clone(), pool: pool.map(str::to_string) });
|
||||
@@ -302,13 +307,10 @@ pub(crate) fn create_project_full_in(
|
||||
id: Some(id.clone()),
|
||||
name: Some(name.to_string()),
|
||||
terminal,
|
||||
archive_home: None,
|
||||
modules: Default::default(),
|
||||
rest: Default::default(),
|
||||
};
|
||||
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);
|
||||
Ok(id)
|
||||
}
|
||||
@@ -357,7 +359,7 @@ pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result
|
||||
}
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
crate::domain::write_atomic(&sp, &(raw + "\n"))
|
||||
}
|
||||
|
||||
/// Nimmt einen bestehenden Ordner als Projekt auf. Eine mitgebrachte
|
||||
@@ -378,13 +380,7 @@ pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
|
||||
.into_owned();
|
||||
check_name(&dirname)?;
|
||||
|
||||
let cfg_path = dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE);
|
||||
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()
|
||||
};
|
||||
let mut cfg = read_config_at(&dir)?;
|
||||
if let Some(id) = cfg.id.as_deref() {
|
||||
if load_registry(paths)?.contains_key(id) {
|
||||
return Err(format!("Projekt ist schon registriert: {id}"));
|
||||
@@ -399,11 +395,6 @@ pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
|
||||
let wd_path = expand_home(paths, &wd);
|
||||
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);
|
||||
Ok(())
|
||||
}
|
||||
@@ -419,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 sp = settings_path(&project_dir(paths, name)?);
|
||||
let mut v: serde_json::Value = if sp.is_file() {
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
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)) {
|
||||
return Err(format!("schon eingetragen: {dir}"));
|
||||
}
|
||||
dirs.push(serde_json::json!(dir));
|
||||
let allow = perms
|
||||
.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}/**)")));
|
||||
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())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
crate::domain::update_settings_permissions(&sp, true, |perms| {
|
||||
let dirs = crate::domain::perm_array(perms, "additionalDirectories")?;
|
||||
if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
|
||||
return Err(format!("schon eingetragen: {dir}"));
|
||||
}
|
||||
dirs.push(serde_json::json!(dir));
|
||||
let allow = crate::domain::perm_array(perms, "allow")?;
|
||||
allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
|
||||
@@ -457,25 +427,18 @@ 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> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
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 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();
|
||||
dirs.retain(|d| d.as_str() != Some(dir));
|
||||
if dirs.len() == before {
|
||||
return Err(format!("nicht eingetragen: {dir}"));
|
||||
}
|
||||
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}/**)")));
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
crate::domain::update_settings_permissions(&sp, false, |perms| {
|
||||
let dirs = crate::domain::perm_array(perms, "additionalDirectories")?;
|
||||
let before = dirs.len();
|
||||
dirs.retain(|d| d.as_str() != Some(dir));
|
||||
if dirs.len() == before {
|
||||
return Err(format!("nicht eingetragen: {dir}"));
|
||||
}
|
||||
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}/**)")));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
|
||||
@@ -500,27 +463,102 @@ pub(crate) fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<Stri
|
||||
)
|
||||
}
|
||||
|
||||
/// Löscht den Projektordner und den Registry-Eintrag; optional auch die
|
||||
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
|
||||
pub(crate) fn delete_project_in(
|
||||
/// Vorschau für den Lösch-Dialog: welche Artefakte die drei Stufen
|
||||
/// (Integration / Integration+Archiv / komplett) konkret treffen würden.
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeletePreview {
|
||||
pub(crate) name: String,
|
||||
pub(crate) project_dir: String,
|
||||
pub(crate) ai_control_dir: bool,
|
||||
pub(crate) panel_files: usize,
|
||||
/// Trilium-Datenverzeichnis der Projekt-Instanz (~-relativ), sofern es
|
||||
/// existiert — das Archiv der Lösch-Stufe "archive".
|
||||
pub(crate) trilium_data: Option<String>,
|
||||
pub(crate) work_dirs: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn delete_preview_in(paths: &Paths, project: &str) -> Result<DeletePreview, String> {
|
||||
check_name(project)?;
|
||||
let dir = project_dir(paths, project)?;
|
||||
let cfg = read_project_config_in(paths, project)?;
|
||||
let tri = crate::domain::trilium::data_dir(paths, project);
|
||||
Ok(DeletePreview {
|
||||
name: cfg.name.clone().unwrap_or_else(|| project.to_string()),
|
||||
project_dir: contract_home(paths, &dir),
|
||||
ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_dir(),
|
||||
panel_files: session_files(paths, project).iter().filter(|f| f.is_file()).count(),
|
||||
trilium_data: tri.is_dir().then(|| contract_home(paths, &tri)),
|
||||
work_dirs: project_work_dirs_in(paths, project)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Flüchtige Panel-Kanaldateien des Projekts unter ~/.config/ai-control/panels.
|
||||
/// 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");
|
||||
crate::domain::modules::MODULES
|
||||
.iter()
|
||||
.flat_map(|m| m.buffers)
|
||||
.map(|b| panels.join(format!("{project}.{}", b.suffix)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
|
||||
/// vorige ein):
|
||||
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
|
||||
/// Panel-Dateien, .desktop. Ordner, memory/ und das Claude-Code-Grundgerüst
|
||||
/// der settings.json bleiben.
|
||||
/// - "archive": zusätzlich das Trilium-Archiv der Projekt-Instanz
|
||||
/// (Datenverzeichnis + Keyring-Token; die Instanz wird vorher gestoppt).
|
||||
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
|
||||
pub(crate) fn delete_project_scoped_in(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
project: &str,
|
||||
scope: &str,
|
||||
delete_work_dirs: bool,
|
||||
) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = project_dir(paths, name)?;
|
||||
check_name(project)?;
|
||||
let dir = project_dir(paths, project)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Projekt nicht gefunden: {name}"));
|
||||
return Err(format!("Projektordner nicht gefunden: {}", dir.display()));
|
||||
}
|
||||
if delete_work_dirs {
|
||||
for wd in project_work_dirs_in(paths, name)? {
|
||||
let wd_path = expand_home(paths, &wd);
|
||||
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
|
||||
if scope == "archive" || scope == "full" {
|
||||
crate::domain::trilium::stop_instance(project);
|
||||
let tri = crate::domain::trilium::data_dir(paths, project);
|
||||
if tri.is_dir() {
|
||||
fs::remove_dir_all(&tri).map_err(|e| format!("{}: {e}", tri.display()))?;
|
||||
}
|
||||
crate::domain::trilium::delete_token(project);
|
||||
}
|
||||
|
||||
match scope {
|
||||
"integration" | "archive" => {
|
||||
let ac = dir.join(PROJECT_CONFIG_DIR);
|
||||
if ac.is_dir() {
|
||||
fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?;
|
||||
}
|
||||
}
|
||||
"full" => {
|
||||
if delete_work_dirs {
|
||||
for wd in project_work_dirs_in(paths, project)? {
|
||||
let wd_path = expand_home(paths, &wd);
|
||||
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
|
||||
}
|
||||
}
|
||||
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
other => return Err(format!("unbekannter Lösch-Umfang: {other}")),
|
||||
}
|
||||
|
||||
for f in session_files(paths, project) {
|
||||
if f.is_file() {
|
||||
fs::remove_file(&f).map_err(|e| format!("{}: {e}", f.display()))?;
|
||||
}
|
||||
}
|
||||
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
crate::domain::registry::unregister_project(paths, name)?;
|
||||
crate::platform::remove_terminal_desktop(paths, name);
|
||||
crate::domain::registry::unregister_project(paths, project)?;
|
||||
crate::platform::remove_terminal_desktop(paths, project);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -549,20 +587,8 @@ pub(crate) fn set_terminal_config_in(
|
||||
// Dateiname speichern — das Icon synct damit mit dem Projekt.
|
||||
if let Some(icon) = terminal.icon.as_deref() {
|
||||
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);
|
||||
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()))?;
|
||||
}
|
||||
terminal.icon = Some(name);
|
||||
terminal.icon = Some(adopt_icon(std::path::Path::new(icon), &cfg_dir)?);
|
||||
}
|
||||
}
|
||||
// Die Terminal-Config kommt aus der Oberfläche und kennt nur die drei Felder;
|
||||
@@ -574,6 +600,23 @@ pub(crate) fn set_terminal_config_in(
|
||||
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
|
||||
/// Projekt-Config-Ordner (.ai-control) des Projekts.
|
||||
pub(crate) fn resolve_icon_path(
|
||||
@@ -588,6 +631,46 @@ pub(crate) fn resolve_icon_path(
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest eine Datei für `write_panel(path)` — den promptfreien MCP-Weg.
|
||||
/// Erlaubt ist genau, was die Session über ihre Permissions ohnehin promptfrei
|
||||
/// liest: Projektordner und Arbeitsordner. Geprüft wird nach
|
||||
/// `canonicalize` (symlink-fest), nur reguläre Dateien bis 2 MB — sonst
|
||||
/// umginge das Tool das Read-Permission-Modell von Claude Code, und ein
|
||||
/// Device wie /dev/zero hinge den Prozess.
|
||||
pub(crate) fn read_for_panel_in(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
src: &str,
|
||||
) -> Result<String, String> {
|
||||
const MAX: u64 = 2 * 1024 * 1024;
|
||||
let canon = fs::canonicalize(expand_home(paths, src)).map_err(|e| format!("{src}: {e}"))?;
|
||||
let mut roots = vec![project_dir(paths, project)?];
|
||||
for d in project_work_dirs_in(paths, project)? {
|
||||
roots.push(expand_home(paths, &d));
|
||||
}
|
||||
let allowed = roots
|
||||
.iter()
|
||||
.filter_map(|r| fs::canonicalize(r).ok())
|
||||
.any(|r| canon.starts_with(&r));
|
||||
if !allowed {
|
||||
return Err(format!(
|
||||
"{src}: liegt außerhalb von Projekt- und Arbeitsordnern"
|
||||
));
|
||||
}
|
||||
let f = fs::File::open(&canon).map_err(|e| format!("{src}: {e}"))?;
|
||||
let meta = f.metadata().map_err(|e| format!("{src}: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{src}: keine reguläre Datei"));
|
||||
}
|
||||
if meta.len() > MAX {
|
||||
return Err(format!("{src}: größer als 2 MB"));
|
||||
}
|
||||
let mut text = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::Read::take(f, MAX), &mut text)
|
||||
.map_err(|e| format!("{src}: {e}"))?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Migration beim App-Start (idempotent): ai-control.json aus dem Projekt-Root
|
||||
/// nach .ai-control/config.json, die Pool-Zuordnung daraus in die Registry,
|
||||
/// Projekt-Icons aus dem zentralen Icons-Verzeichnis in den Projekt-Config-Ordner.
|
||||
@@ -615,7 +698,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
|
||||
if !cfg_path.exists() && !obj.is_empty() {
|
||||
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||||
crate::domain::write_atomic(&cfg_path, &(raw + "\n"))?;
|
||||
}
|
||||
fs::remove_file(&legacy).map_err(|e| format!("{}: {e}", legacy.display()))?;
|
||||
}
|
||||
@@ -624,13 +707,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
|
||||
// war der Anzeigename.
|
||||
let cfg_dir = entry.dir.join(PROJECT_CONFIG_DIR);
|
||||
let cfg_path = cfg_dir.join(PROJECT_FILE);
|
||||
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()
|
||||
};
|
||||
let mut cfg = read_config_at(&entry.dir)?;
|
||||
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();
|
||||
cfg.name.get_or_insert_with(|| key.clone());
|
||||
@@ -642,16 +719,8 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
|
||||
if !icon.starts_with('/') {
|
||||
let old = paths.icons_dir().join(&icon);
|
||||
if old.is_file() {
|
||||
let ext = std::path::Path::new(&icon)
|
||||
.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()))?;
|
||||
cfg.terminal.icon = Some(adopt_icon(&old, &cfg_dir)?);
|
||||
fs::remove_file(&old).map_err(|e| format!("{}: {e}", old.display()))?;
|
||||
cfg.terminal.icon = Some(name);
|
||||
cfg_dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -660,7 +729,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
|
||||
if cfg_dirty {
|
||||
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
|
||||
let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
|
||||
fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||||
crate::domain::write_atomic(&cfg_path, &(raw + "\n"))?;
|
||||
}
|
||||
if id != key {
|
||||
reg_dirty = true;
|
||||
@@ -677,7 +746,10 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
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
|
||||
/// mitgeklonten Alt-Datei in die Registry gelangt — darf kein beliebiges
|
||||
@@ -730,7 +802,6 @@ mod tests {
|
||||
title: Some("Neu".into()),
|
||||
..Default::default()
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -765,21 +836,14 @@ mod tests {
|
||||
"Edit(~/claude-projects/neu/**)"
|
||||
);
|
||||
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
|
||||
// todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da
|
||||
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));
|
||||
assert!(settings.get("hooks").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
|
||||
let p = tmp_paths();
|
||||
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();
|
||||
let dir = p.projects_dir().join("neu");
|
||||
assert!(dir.join(".claude").join("settings.json").is_file());
|
||||
@@ -804,7 +868,7 @@ mod tests {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "neu").unwrap();
|
||||
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();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
@@ -820,7 +884,6 @@ mod tests {
|
||||
Some("~/projects/gibtsnicht"),
|
||||
false,
|
||||
TerminalConfig::default(),
|
||||
false,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Arbeitsverzeichnis fehlt"));
|
||||
@@ -991,7 +1054,8 @@ mod tests {
|
||||
let cfg = read_project_config_in(&p, &id).unwrap();
|
||||
assert_eq!(cfg.id.as_deref(), Some(id.as_str()));
|
||||
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.
|
||||
assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png"));
|
||||
assert!(dir.join(PROJECT_CONFIG_DIR).join("icon.png").is_file());
|
||||
@@ -1046,8 +1110,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Import eines Ordners mit mitgebrachter Config: die ID wird übernommen
|
||||
/// (Sync-Fall), Arbeitsordner und Archiv-Home werden angelegt, die
|
||||
/// Archiv-Permission nachgezogen.
|
||||
/// (Sync-Fall), Arbeitsordner werden angelegt.
|
||||
#[test]
|
||||
fn projekt_import_uebernimmt_id_und_legt_pfade_an() {
|
||||
let p = tmp_paths();
|
||||
@@ -1055,7 +1118,7 @@ mod tests {
|
||||
fs::create_dir_all(dir.join(PROJECT_CONFIG_DIR)).unwrap();
|
||||
fs::write(
|
||||
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();
|
||||
fs::create_dir_all(dir.join(".claude")).unwrap();
|
||||
@@ -1070,10 +1133,6 @@ mod tests {
|
||||
assert_eq!(project_dir(&p, "sync-id").unwrap(), dir);
|
||||
assert_eq!(display_name_in(&p, "sync-id").unwrap(), "Mein Projekt");
|
||||
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.
|
||||
let err = add_project_in(&p, &dir.display().to_string()).unwrap_err();
|
||||
@@ -1107,16 +1166,16 @@ mod tests {
|
||||
fn projekt_loeschen() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
delete_project_in(&p, "proj", false).unwrap();
|
||||
delete_project_scoped_in(&p, "proj", "full", false).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_loeschen_laesst_arbeitsordner() {
|
||||
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();
|
||||
delete_project_in(&p, &id, false).unwrap();
|
||||
delete_project_scoped_in(&p, &id, "full", false).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
assert!(p.home.join("projects").join("proj").is_dir());
|
||||
}
|
||||
@@ -1124,9 +1183,9 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_mit_arbeitsordner() {
|
||||
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();
|
||||
delete_project_in(&p, &id, true).unwrap();
|
||||
delete_project_scoped_in(&p, &id, "full", true).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
assert!(!p.home.join("projects").join("proj").exists());
|
||||
}
|
||||
@@ -1134,16 +1193,16 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
|
||||
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();
|
||||
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
|
||||
assert!(delete_project_in(&p, &id, true).is_err());
|
||||
assert!(delete_project_scoped_in(&p, &id, "full", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_arbeitsordner_auslesen() {
|
||||
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();
|
||||
assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/proj"]);
|
||||
// Projekt ohne settings.json → leer
|
||||
@@ -1161,7 +1220,7 @@ mod tests {
|
||||
#[test]
|
||||
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
|
||||
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();
|
||||
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
|
||||
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
|
||||
@@ -1183,7 +1242,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projektordner_verlegen() {
|
||||
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();
|
||||
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
|
||||
let new_dir = p.home.join("elsewhere").join("proj");
|
||||
@@ -1200,9 +1259,100 @@ mod tests {
|
||||
assert!(!raw.contains("claude-projects/proj"));
|
||||
}
|
||||
|
||||
// -- write_panel(path): promptfreier Lese-Weg --
|
||||
|
||||
#[test]
|
||||
fn panel_lesen_nur_aus_projekt_und_arbeitsordnern() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
let dir = p.projects_dir().join("proj");
|
||||
fs::write(dir.join("notiz.md"), "inhalt").unwrap();
|
||||
assert_eq!(read_for_panel_in(&p, "proj", &dir.join("notiz.md").display().to_string()).unwrap(), "inhalt");
|
||||
|
||||
// außerhalb: abgelehnt
|
||||
fs::write(p.home.join("geheim.txt"), "x").unwrap();
|
||||
let err = read_for_panel_in(&p, "proj", &p.home.join("geheim.txt").display().to_string())
|
||||
.unwrap_err();
|
||||
assert!(err.contains("außerhalb"));
|
||||
|
||||
// Symlink aus dem Projekt hinaus: canonicalize entlarvt das Ziel
|
||||
std::os::unix::fs::symlink(p.home.join("geheim.txt"), dir.join("link.txt")).unwrap();
|
||||
assert!(read_for_panel_in(&p, "proj", &dir.join("link.txt").display().to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_lesen_groessenlimit_und_geraetedateien() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
let dir = p.projects_dir().join("proj");
|
||||
fs::write(dir.join("gross.bin"), vec![b'x'; 2 * 1024 * 1024 + 1]).unwrap();
|
||||
let err = read_for_panel_in(&p, "proj", &dir.join("gross.bin").display().to_string())
|
||||
.unwrap_err();
|
||||
assert!(err.contains("2 MB"));
|
||||
// Geräte-Datei liegt eh außerhalb der Wurzeln — der Ablehnungsgrund davor
|
||||
assert!(read_for_panel_in(&p, "proj", "/dev/zero").is_err());
|
||||
}
|
||||
|
||||
/// Stufe „nur Integration": ai-control-Spuren weg, Ordner und
|
||||
/// Claude-Code-Bestand bleiben.
|
||||
#[test]
|
||||
fn loeschen_nur_integration() {
|
||||
let p = tmp_paths();
|
||||
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
|
||||
.unwrap();
|
||||
let dir = p.projects_dir().join("proj");
|
||||
// flüchtige Panel-Datei + Trilium-Datenverzeichnis
|
||||
fs::create_dir_all(p.config_dir().join("panels")).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();
|
||||
assert!(preview.ai_control_dir);
|
||||
assert_eq!(preview.panel_files, 1);
|
||||
assert!(preview.trilium_data.is_some());
|
||||
|
||||
delete_project_scoped_in(&p, &id, "integration", false).unwrap();
|
||||
|
||||
assert!(dir.is_dir());
|
||||
assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
|
||||
let sp = fs::read_to_string(settings_path(&dir)).unwrap();
|
||||
assert!(sp.contains("autoMemoryDirectory")); // Claude-Code-Bestand bleibt
|
||||
assert!(tri.is_dir()); // Archiv bleibt
|
||||
assert!(!p.config_dir().join("panels").join(format!("{id}.commands.jsonl")).exists());
|
||||
assert!(load_registry(&p).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Stufe „Integration & Archiv": zusätzlich stirbt das Trilium-Datadir.
|
||||
#[test]
|
||||
fn loeschen_integration_und_archiv() {
|
||||
let p = tmp_paths();
|
||||
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default())
|
||||
.unwrap();
|
||||
let tri = crate::domain::trilium::data_dir(&p, &id);
|
||||
fs::create_dir_all(&tri).unwrap();
|
||||
|
||||
delete_project_scoped_in(&p, &id, "archive", false).unwrap();
|
||||
|
||||
assert!(p.projects_dir().join("proj").is_dir());
|
||||
assert!(!tri.exists());
|
||||
assert!(load_registry(&p).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loeschen_unbekannter_umfang_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
assert!(delete_project_scoped_in(&p, "proj", "halb", false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_loeschen_unbekannt_scheitert() {
|
||||
let p = tmp_paths();
|
||||
assert!(delete_project_in(&p, "gibtsnicht", false).is_err());
|
||||
assert!(delete_project_scoped_in(&p, "gibtsnicht", "full", false).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ pub(crate) fn save_registry(
|
||||
fs::create_dir_all(paths.config_dir())
|
||||
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
|
||||
let file = paths.projects_file();
|
||||
fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
|
||||
crate::domain::write_atomic(&file, &(raw + "\n"))
|
||||
}
|
||||
|
||||
/// Ordner eines registrierten Projekts.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 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::path::PathBuf;
|
||||
@@ -53,27 +53,42 @@ pub(crate) fn set_terminal_font_size_in(paths: &Paths, size: u32) -> Result<(),
|
||||
write_app_setting(paths, "terminalFontSize", serde_json::json!(size))
|
||||
}
|
||||
|
||||
/// Standard-Sprache der Rechtschreibprüfung (settings.json: spellcheckLang),
|
||||
/// Default „de". Pro Text im Panel überschreibbar.
|
||||
pub(crate) fn spellcheck_lang(paths: &Paths) -> String {
|
||||
/// Hat der Nutzer das Angebot, die App nach ~/Applications zu holen, schon
|
||||
/// abgelehnt? (settings.json: moveOfferDismissed, nur macOS)
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub(crate) fn move_offer_dismissed(paths: &Paths) -> bool {
|
||||
read_app_settings(paths)
|
||||
.and_then(|v| v["spellcheckLang"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "de".to_string())
|
||||
.and_then(|v| v["moveOfferDismissed"].as_bool())
|
||||
.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.
|
||||
///
|
||||
/// Nur eine *fehlende* Datei rechtfertigt ein frisches Objekt. Unlesbar oder
|
||||
/// kaputtes JSON bricht ab — sonst würde ein einziges Verstellen der
|
||||
/// Schriftgröße claudeCommand, poolSyncDir & Co. endgültig verwerfen
|
||||
/// (read_app_settings wirft für die Lese-Getter alles in denselben None).
|
||||
fn write_app_setting(
|
||||
paths: &Paths,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let path = paths.config_dir().join(APP_SETTINGS_FILE);
|
||||
let mut v = read_app_settings(paths).unwrap_or_else(|| serde_json::json!({}));
|
||||
let mut v: serde_json::Value = match fs::read_to_string(&path) {
|
||||
Ok(raw) => serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", path.display()))?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
|
||||
Err(e) => return Err(format!("{}: {e}", path.display())),
|
||||
};
|
||||
v[key] = value;
|
||||
fs::create_dir_all(paths.config_dir())
|
||||
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
|
||||
crate::domain::write_atomic(&path, &(raw + "\n"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user