Archiv-Wiki im Panel: vier Tabs, FTS5-Suche, strukturierbares Archiv
- Archiv-Home pro Projekt (archiveHome in ai-control.json); archive_panel mit Ordner, Beschreibung und Schlagwörtern (Frontmatter + Zeitstempel-Stem) - Archiv-Index (archive_index.rs): Scan mit Frontmatter, Wikilinks, Backlinks; Wiki-Seiten als strukturierte Daten (Übersicht/Tag-Seiten) - FTS5-Suche (archive_search.rs, rusqlite bundled): in-memory-Index pro Anfrage; MCP search_archive und Panel-Suchfeld (search_run) teilen die Engine - Panel: vier Puffer/Ansichten (Dokument, Befehle, Suche, Wiki) mit Tabs im Fenster-Header; gemeinsame Verdrahtung panel-wiring.ts, Kachel-CSS, Wiki-View mit Tag-Chips, Ordner-Sektionen und Backlinks - Archivieren speichert offene Bearbeitung (flush); Tab-Wechsel speichert statt zu blockieren; Fehler sichtbar als Panel-Toast - Abgelöstes Fenster: rahmenlos (Linux), eigene Kopfleiste mit Tabs und Fensterknöpfen, Theme-Kopplung über gemeinsames themes.ts - MCP-Tools show_commands, show_archive; Befehls-History mit Kachel-Löschen
This commit is contained in:
Generated
+370
-2
@@ -19,6 +19,18 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -43,12 +55,14 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"portable-pty",
|
||||
"rfd 0.15.4",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"uuid",
|
||||
@@ -111,6 +125,27 @@ version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -660,6 +695,15 @@ dependencies = [
|
||||
"vec_map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -772,6 +816,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -1031,7 +1081,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"cssparser",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
@@ -1179,6 +1229,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error-code"
|
||||
version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -1200,12 +1256,30 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1251,6 +1325,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@@ -1267,6 +1347,12 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1508,6 +1594,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1690,13 +1786,42 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ahash 0.7.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1705,6 +1830,15 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -1984,6 +2118,7 @@ dependencies = [
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2232,6 +2367,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2388,6 +2534,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2674,6 +2829,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2734,6 +2899,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -3002,6 +3178,12 @@ version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -3274,6 +3456,20 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.42.1"
|
||||
@@ -4077,6 +4273,21 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-clipboard-manager"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
@@ -4313,6 +4524,20 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.53"
|
||||
@@ -4615,6 +4840,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -4764,6 +5000,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
@@ -4904,6 +5146,76 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.103"
|
||||
@@ -5006,6 +5318,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -5480,6 +5798,24 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix",
|
||||
"thiserror 2.0.18",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
@@ -5560,6 +5896,23 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
@@ -5750,6 +6103,21 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.12.0"
|
||||
|
||||
@@ -29,10 +29,13 @@ tauri = { version = "2.11.3", features = ["macos-private-api", "tray-icon", "ima
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
portable-pty = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# Volltext-Suche übers Panel-Archiv (SQLite FTS5, in-memory aus dem Baum gebaut).
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
keyring = { version = "3", features = ["apple-native", "sync-secret-service", "windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"autostart:default",
|
||||
"dialog:default"
|
||||
"dialog:default",
|
||||
"clipboard-manager:allow-write-text"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
---
|
||||
name: panel
|
||||
description: Beim Entwerfen längerer Texte (ADR, E-Mail, Dokument, Spezifikation, Commit-Message, Textbaustein) das MCP-Tool write_panel nutzen, statt den Text als Fließtext in den Chat zu schreiben — dann ist er im ai-control-Panel mit Maus selektier- und kopierbar. Auch auf ausdrückliche Bitte nutzen ("ins Panel", "ins Panel schreiben").
|
||||
description: Beim Entwerfen längerer Texte (ADR, E-Mail, Dokument, Spezifikation, Commit-Message, Textbaustein) das MCP-Tool write_panel nutzen, statt den Text als Fließtext in den Chat zu schreiben; für eine bestehende Datei write_panel mit path aufrufen. Befehle, die der Nutzer ausführen soll, IMMER über write_commands als kopierbare Kacheln ausgeben statt als Codeblock im Chat. Will der Nutzer die Befehlsliste sehen ("zeig die Befehle", "zeige die Befehlsliste"), show_commands aufrufen. Auch auf ausdrückliche Bitte nutzen ("ins Panel", "ins Panel schreiben").
|
||||
---
|
||||
|
||||
# Panel
|
||||
|
||||
Claude Code läuft im TUI; sichtbarer Text lässt sich dort nicht sauber selektieren und kopieren. Die ai-control-App zeigt Text in einem andockbaren Panel neben dem Terminal an — selektierbar, mit Copy-Button. Der Kanal dahin ist das MCP-Tool `write_panel` (Server `aicontrol`).
|
||||
Claude Code läuft im TUI; sichtbarer Text lässt sich dort nicht sauber selektieren und kopieren. Die ai-control-App zeigt Inhalte in einem andockbaren Panel neben dem Terminal an — selektierbar, mit Copy-Buttons. Die Kanäle dahin sind die MCP-Tools `write_panel` und `write_commands` (Server `text-panel`).
|
||||
|
||||
## Wann
|
||||
## Entwürfe — `write_panel`
|
||||
|
||||
- Automatisch, sobald ein längerer Fließtext oder ein Dokument entworfen wird (ADR, E-Mail, Spezifikation, Commit-Message, Textbaustein).
|
||||
- Auf ausdrückliche Bitte, etwas ins Panel zu legen.
|
||||
- Automatisch, sobald ein längerer Fließtext oder ein Dokument entworfen wird (ADR, E-Mail, Spezifikation, Commit-Message, Textbaustein); außerdem auf ausdrückliche Bitte, etwas ins Panel zu legen.
|
||||
- Neuer Text: vollständigen Entwurf als Argument `text` (Markdown-Rohtext, das Panel rendert ihn).
|
||||
- Bestehende Datei: **immer** `path` statt `text` übergeben — der Server liest die Datei selbst von der Platte, der Inhalt wird nicht abgetippt.
|
||||
- Den Text nicht zusätzlich als Fließtext in den Chat schreiben — im Chat nur kurz bestätigen (z. B. „Entwurf im Panel.").
|
||||
- Nicht für normale Chat-Antworten, kurze Bestätigungen oder Code im laufenden Dialog.
|
||||
|
||||
Nicht für normale Chat-Antworten, kurze Bestätigungen oder Code im laufenden Dialog.
|
||||
## Befehle — `write_commands`
|
||||
|
||||
## Vorgehen
|
||||
- IMMER nutzen, wenn ein oder mehrere Shell-Befehle für den Nutzer bestimmt sind (er soll sie ausführen) — statt sie als Codeblock in den Chat zu schreiben.
|
||||
- Argument `commands`: Array in Ausführungsreihenfolge, je Eintrag `cmd` (exakt ausführbar) und optional `note` (Kurznotiz).
|
||||
- Das Panel zeigt sie als Kacheln mit Copy-Button, angehängt an die Befehls-History der Session (flüchtig, startet mit jeder Session leer); im Chat nur kurz einordnen, den Befehl nicht doppelt ausgeben.
|
||||
- Nicht für Befehle, die Claude selbst ausführt, oder für Code, der nur erklärt wird.
|
||||
|
||||
1. `write_panel` mit dem vollständigen Entwurf als Argument `text` aufrufen (Markdown-Rohtext, das Panel rendert ihn). Das Panel blendet sich beim Schreiben automatisch ein.
|
||||
2. Den Text **nicht** zusätzlich als Fließtext in den Chat schreiben — im Chat nur kurz bestätigen (z. B. „Entwurf im Panel.").
|
||||
3. Ist das Tool nicht verfügbar (Terminal außerhalb von ai-control), den Text normal im Chat ausgeben.
|
||||
## Befehlsliste zeigen — `show_commands`
|
||||
|
||||
- Nutzen, wenn der Nutzer die bisherige Befehlsliste sehen will („zeig die Befehle", „zeige die Befehlsliste"): schaltet das Panel auf die Kachel-Ansicht der History, ohne etwas anzuhängen. Keine Argumente.
|
||||
|
||||
## Panel leer öffnen
|
||||
|
||||
Soll das Panel ohne konkreten Inhalt geöffnet werden („mach das Panel auf", „Panel öffnen"), `write_panel` mit dem neutralen Platzhalter `Panel` aufrufen — leerer Text blendet das Panel nicht ein. Keinen erklärenden, werbenden oder ausgedachten Einleitungstext erfinden.
|
||||
|
||||
## Fallback
|
||||
|
||||
Sind die Tools nicht verfügbar (Terminal außerhalb von ai-control), Text bzw. Befehle normal im Chat ausgeben.
|
||||
|
||||
@@ -277,6 +277,7 @@ pub(crate) fn main_builder() -> tauri::Builder<tauri::Wry> {
|
||||
pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.manage(terminal::Terminals::default())
|
||||
.setup(move |app| {
|
||||
let cfg = terminal_config(&project)?;
|
||||
@@ -302,10 +303,17 @@ pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
|
||||
terminal::term_write,
|
||||
terminal::term_resize,
|
||||
terminal::panel_read,
|
||||
terminal::commands_read,
|
||||
terminal::commands_delete,
|
||||
terminal::panel_set,
|
||||
terminal::search_read,
|
||||
terminal::search_run,
|
||||
terminal::wiki_read,
|
||||
terminal::wiki_open,
|
||||
terminal::open_panel_window,
|
||||
commands::panel_archive_dir_cmd,
|
||||
commands::panel_archive_cmd,
|
||||
commands::archive_docs_cmd,
|
||||
commands::reveal_path_cmd,
|
||||
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
|
||||
commands::list_projects,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::domain::archive::{archive_panel_content, project_archive_dir};
|
||||
use crate::domain::archive::{
|
||||
archive_panel_content, project_archive_home, require_archive_home, ArchiveMeta,
|
||||
};
|
||||
use crate::domain::credentials::keychain_service;
|
||||
use crate::domain::paths::Paths;
|
||||
use crate::domain::pool::{
|
||||
@@ -333,18 +335,28 @@ pub(crate) fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
|
||||
|
||||
// ---------- Panel-Archiv ----------
|
||||
|
||||
/// Konfigurierter Archiv-Ordner des Projekts (für den Panel-Button: entscheidet,
|
||||
/// Konfiguriertes Archiv-Home des Projekts (für den Panel-Button: entscheidet,
|
||||
/// ob der Ordner-Dialog nötig ist).
|
||||
#[tauri::command]
|
||||
pub(crate) fn panel_archive_dir_cmd(project: String) -> Option<String> {
|
||||
project_archive_dir(&project).map(|p| p.display().to_string())
|
||||
project_archive_home(&project).map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Archiviert den aktuellen Panel-Entwurf. `dir` (aus dem Ordner-Dialog) setzt
|
||||
/// den Archiv-Ordner zugleich; ohne `dir` muss er konfiguriert sein.
|
||||
/// das Archiv-Home zugleich; ohne `dir` muss es konfiguriert sein.
|
||||
#[tauri::command]
|
||||
pub(crate) fn panel_archive_cmd(project: String, dir: Option<String>) -> Result<String, String> {
|
||||
archive_panel_content(&project, dir.as_deref()).map(|p| p.display().to_string())
|
||||
archive_panel_content(&project, dir.as_deref(), &ArchiveMeta::default())
|
||||
.map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Dokumentliste des Archiv-Index (frisch gescannt) fürs Panel.
|
||||
#[tauri::command]
|
||||
pub(crate) fn archive_docs_cmd(
|
||||
project: String,
|
||||
) -> Result<Vec<crate::domain::archive_index::Doc>, String> {
|
||||
let home = require_archive_home(&project)?;
|
||||
crate::domain::archive_index::scan_archive(&home)
|
||||
}
|
||||
|
||||
/// Zeigt einen Pfad im Dateimanager (nach dem Archivieren „im Finder zeigen").
|
||||
|
||||
+180
-15
@@ -11,18 +11,23 @@ use crate::domain::project::{
|
||||
};
|
||||
use crate::domain::registry::project_dir;
|
||||
|
||||
/// Konfigurierter Archiv-Ordner des Projekts (ai-control.json: archiveDir),
|
||||
/// Konfiguriertes Archiv-Home des Projekts (ai-control.json: archiveHome),
|
||||
/// Home-expandiert; None, wenn nicht gesetzt.
|
||||
pub(crate) fn project_archive_dir(project: &str) -> Option<PathBuf> {
|
||||
pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
|
||||
let paths = Paths::real();
|
||||
let dir = read_project_config_in(&paths, project).ok()?.archive_dir?;
|
||||
let dir = read_project_config_in(&paths, project).ok()?.archive_home?;
|
||||
Some(expand_home(&paths, &dir))
|
||||
}
|
||||
|
||||
/// Setzt den Archiv-Ordner: ai-control.json (~-relativ) und ein Eintrag in
|
||||
/// Archiv-Home als Result — die gemeinsame Vorbedingung aller Archiv-Tools.
|
||||
pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
|
||||
project_archive_home(project).ok_or_else(|| "kein Archiv-Ordner gesetzt".into())
|
||||
}
|
||||
|
||||
/// Setzt das Archiv-Home: ai-control.json (~-relativ) und ein Eintrag in
|
||||
/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
|
||||
/// claude das Archiv später lesen/scannen darf.
|
||||
pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), String> {
|
||||
pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), String> {
|
||||
let paths = Paths::real();
|
||||
let expanded = expand_home(&paths, dir);
|
||||
if !expanded.is_absolute() {
|
||||
@@ -39,7 +44,7 @@ pub(crate) fn set_project_archive_dir(project: &str, dir: &str) -> Result<(), St
|
||||
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
|
||||
let contracted = contract_home(&paths, &expanded);
|
||||
let mut cfg = read_project_config_in(&paths, project)?;
|
||||
cfg.archive_dir = Some(contracted.clone());
|
||||
cfg.archive_home = Some(contracted.clone());
|
||||
write_project_config_in(&paths, project, &cfg)?;
|
||||
add_archive_permission(&paths, project, &contracted)
|
||||
}
|
||||
@@ -82,17 +87,33 @@ fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(),
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ArchiveMeta {
|
||||
/// Unterordner relativ zum Archiv-Home (wird angelegt).
|
||||
pub(crate) folder: Option<String>,
|
||||
/// Einzeiler fürs Frontmatter.
|
||||
pub(crate) description: Option<String>,
|
||||
/// Schlagwörter fürs Frontmatter.
|
||||
pub(crate) tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Archiviert den aktuellen Panel-Inhalt des Projekts als Markdown-Datei mit
|
||||
/// Frontmatter im Archiv-Ordner. `dir_override` setzt den Ordner zugleich
|
||||
/// Frontmatter im Archiv-Home. `dir_override` setzt das Home zugleich
|
||||
/// (Terminal-Fallback ohne Dialog). Liefert den geschriebenen Pfad.
|
||||
pub(crate) fn archive_panel_content(
|
||||
project: &str,
|
||||
dir_override: Option<&str>,
|
||||
meta: &ArchiveMeta,
|
||||
) -> Result<PathBuf, String> {
|
||||
if let Some(d) = dir_override {
|
||||
set_project_archive_dir(project, d)?;
|
||||
set_project_archive_home(project, d)?;
|
||||
}
|
||||
let dir = project_archive_dir(project).ok_or("kein Archiv-Ordner gesetzt")?;
|
||||
let home = require_archive_home(project)?;
|
||||
let dir = match &meta.folder {
|
||||
Some(f) => home.join(check_folder(f)?),
|
||||
None => home,
|
||||
};
|
||||
let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
return Err("Panel ist leer — nichts zu archivieren".into());
|
||||
@@ -106,15 +127,110 @@ pub(crate) fn archive_panel_content(
|
||||
let (stamp, iso) = utc_stamp(secs);
|
||||
let title = first_line(&text);
|
||||
let path = dir.join(format!("{stamp}-{}.md", slugify(&title)));
|
||||
let doc = format!(
|
||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n---\n\n{}\n",
|
||||
title.replace('"', "'"),
|
||||
text.trim_end(),
|
||||
);
|
||||
let doc = format!("{}{}\n", frontmatter(&title, project, &iso, meta), text.trim_end());
|
||||
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root).
|
||||
fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
|
||||
let p = std::path::Path::new(folder);
|
||||
let normal = p
|
||||
.components()
|
||||
.all(|c| matches!(c, std::path::Component::Normal(_)));
|
||||
if p.components().next().is_some() && normal {
|
||||
Ok(p)
|
||||
} else {
|
||||
Err(format!("Unterordner muss ein relativer Pfad ohne '..' sein: {folder}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// YAML-Frontmatter des Archiv-Dokuments inklusive optionaler
|
||||
/// description/tags aus den Metadaten. Gegenstück: `parse_frontmatter` unten —
|
||||
/// Schreiber und Leser des Formats leben bewusst im selben Modul.
|
||||
fn frontmatter(title: &str, project: &str, iso: &str, meta: &ArchiveMeta) -> String {
|
||||
let mut fm = format!(
|
||||
"---\ntitle: \"{}\"\nproject: {project}\ncreated: {iso}\nsource: ai-control\n",
|
||||
title.replace('"', "'"),
|
||||
);
|
||||
if let Some(d) = &meta.description {
|
||||
fm.push_str(&format!("description: \"{}\"\n", d.replace('"', "'")));
|
||||
}
|
||||
if !meta.tags.is_empty() {
|
||||
let quoted: Vec<String> =
|
||||
meta.tags.iter().map(|t| format!("\"{}\"", t.replace('"', "'"))).collect();
|
||||
fm.push_str(&format!("tags: [{}]\n", quoted.join(", ")));
|
||||
}
|
||||
fm.push_str("---\n\n");
|
||||
fm
|
||||
}
|
||||
|
||||
/// Minimaler Frontmatter-Parser für die selbst geschriebenen Dokumente:
|
||||
/// `key: value`-Zeilen zwischen den beiden `---`-Markern, Anführungszeichen
|
||||
/// um Werte werden entfernt.
|
||||
pub(crate) fn parse_frontmatter(text: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let Some(rest) = text.strip_prefix("---\n") else {
|
||||
return map;
|
||||
};
|
||||
let Some(end) = rest.find("\n---") else {
|
||||
return map;
|
||||
};
|
||||
for line in rest[..end].lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
map.insert(key.trim().to_string(), unquote(value.trim()).to_string());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Dokument-Rumpf ohne den Frontmatter-Block; führende Leerzeilen entfernt.
|
||||
pub(crate) fn strip_frontmatter(text: &str) -> &str {
|
||||
let Some(rest) = text.strip_prefix("---\n") else {
|
||||
return text;
|
||||
};
|
||||
match rest.find("\n---\n") {
|
||||
Some(end) => rest[end + 5..].trim_start_matches('\n'),
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
fn unquote(s: &str) -> &str {
|
||||
s.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Inline-Liste `["a", "b"]` bzw. `[a, b]` in Einzel-Tags zerlegen.
|
||||
pub(crate) fn parse_tag_list(raw: &str) -> Vec<String> {
|
||||
raw
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.split(',')
|
||||
.map(|t| unquote(t.trim()).to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Datei-Stem ohne führenden `YYYY-MM-DD_HHMM-`-Zeitstempel — das Gegenstück
|
||||
/// zum Dateinamen aus `utc_stamp` + `slugify`.
|
||||
pub(crate) fn strip_stamp(stem: &str) -> &str {
|
||||
let bytes = stem.as_bytes();
|
||||
let stamped = bytes.len() > 16
|
||||
&& bytes[..16].iter().enumerate().all(|(i, b)| match i {
|
||||
4 | 7 => *b == b'-',
|
||||
10 => *b == b'_',
|
||||
15 => *b == b'-',
|
||||
_ => b.is_ascii_digit(),
|
||||
});
|
||||
if stamped {
|
||||
&stem[16..]
|
||||
} else {
|
||||
stem
|
||||
}
|
||||
}
|
||||
|
||||
/// Titelzeile: erste Überschrift (## …) oder sonst erste nicht-leere Zeile.
|
||||
fn first_line(text: &str) -> String {
|
||||
let mut fallback: Option<&str> = None;
|
||||
@@ -133,7 +249,7 @@ fn first_line(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// Dateinamen-tauglicher Slug (ascii, klein, Bindestriche, max. 60 Zeichen).
|
||||
fn slugify(s: &str) -> String {
|
||||
pub(crate) fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
@@ -200,6 +316,55 @@ mod tests {
|
||||
assert_eq!(slugify("###"), "entwurf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_und_strip_roundtrip() {
|
||||
let (stamp, _) = utc_stamp(20_645u64 * 86400);
|
||||
assert_eq!(strip_stamp(&format!("{stamp}-adr-logging")), "adr-logging");
|
||||
assert_eq!(strip_stamp("adr-logging"), "adr-logging");
|
||||
assert_eq!(strip_stamp("2026-07-19-adr"), "2026-07-19-adr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_und_parser_roundtrip() {
|
||||
let meta = ArchiveMeta {
|
||||
folder: None,
|
||||
description: Some("Kurz".into()),
|
||||
tags: vec!["adr".into(), "infra".into()],
|
||||
};
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &meta);
|
||||
let map = parse_frontmatter(&fm);
|
||||
assert_eq!(map.get("title").map(String::as_str), Some("Titel"));
|
||||
assert_eq!(map.get("description").map(String::as_str), Some("Kurz"));
|
||||
assert_eq!(parse_tag_list(&map["tags"]), vec!["adr", "infra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_folder_relativ_ohne_punktpunkt() {
|
||||
assert!(check_folder("konzepte/panel").is_ok());
|
||||
assert!(check_folder("../raus").is_err());
|
||||
assert!(check_folder("a/../b").is_err());
|
||||
assert!(check_folder("/absolut").is_err());
|
||||
assert!(check_folder("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_mit_und_ohne_meta() {
|
||||
let leer = ArchiveMeta::default();
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &leer);
|
||||
assert!(fm.starts_with("---\ntitle: \"Titel\"\n"));
|
||||
assert!(!fm.contains("description:"));
|
||||
assert!(!fm.contains("tags:"));
|
||||
|
||||
let voll = ArchiveMeta {
|
||||
folder: None,
|
||||
description: Some("Kurz \"zitiert\"".into()),
|
||||
tags: vec!["archiv".into(), "wiki".into()],
|
||||
};
|
||||
let fm = frontmatter("Titel", "proj", "2026-07-19T10:00:00Z", &voll);
|
||||
assert!(fm.contains("description: \"Kurz 'zitiert'\"\n"));
|
||||
assert!(fm.contains("tags: [\"archiv\", \"wiki\"]\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_ueberschrift_oder_erste_zeile() {
|
||||
assert_eq!(first_line("\n\n# Titel\n\nText"), "Titel");
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Archiv-Index: reproduzierbare Sicht über den Archiv-Baum eines Projekts —
|
||||
//! pro Markdown-Dokument Name, Frontmatter-Metadaten und Wikilinks; Backlinks
|
||||
//! fallen beim Scan als Nebenprodukt ab. Der Index ist abgeleitete Information
|
||||
//! und wird bei Bedarf frisch aus dem Baum gebaut (nichts davon wird gesynct).
|
||||
//! Das Dateiformat (Frontmatter, Zeitstempel-Stem) definiert archive.rs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::archive::{
|
||||
parse_frontmatter, parse_tag_list, slugify, strip_frontmatter, strip_stamp,
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub(crate) struct Doc {
|
||||
/// Pfad relativ zum Archiv-Home.
|
||||
pub(crate) relpath: String,
|
||||
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
|
||||
pub(crate) name: String,
|
||||
/// Frontmatter-Titel, sonst der Name.
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) tags: Vec<String>,
|
||||
/// Wikilink-Ziele im Dokumenttext (`[[ziel]]`/`[[ziel|label]]`, nur das Ziel).
|
||||
pub(crate) links: Vec<String>,
|
||||
/// Namen der Dokumente, die per Wikilink hierher zeigen.
|
||||
pub(crate) backlinks: Vec<String>,
|
||||
}
|
||||
|
||||
/// Scannt den Archiv-Baum rekursiv über alle Markdown-Dateien; versteckte
|
||||
/// Einträge (Punkt-Präfix) bleiben außen vor. Reihenfolge: relpath sortiert;
|
||||
/// Backlinks sind nach dem Scan gefüllt.
|
||||
pub(crate) fn scan_archive(home: &Path) -> Result<Vec<Doc>, String> {
|
||||
Ok(scan_with_bodies(home)?.into_iter().map(|(doc, _)| doc).collect())
|
||||
}
|
||||
|
||||
/// Wie `scan_archive`, liefert zu jedem Dokument den bereits gelesenen
|
||||
/// Volltext mit — die Suche indexiert damit ohne zweite Lesung pro Datei.
|
||||
pub(crate) fn scan_with_bodies(home: &Path) -> Result<Vec<(Doc, String)>, String> {
|
||||
let mut docs = Vec::new();
|
||||
walk(home, home, &mut docs)?;
|
||||
docs.sort_by(|a, b| a.0.relpath.cmp(&b.0.relpath));
|
||||
fill_backlinks(&mut docs);
|
||||
Ok(docs)
|
||||
}
|
||||
|
||||
/// Backlinks in einem Durchlauf: Slug → Doc-Index einmal aufbauen, jeden Link
|
||||
/// genau einmal auflösen und zu Backlinks invertieren.
|
||||
fn fill_backlinks(docs: &mut [(Doc, String)]) {
|
||||
let mut lookup: HashMap<String, usize> = HashMap::new();
|
||||
for (i, (doc, _)) in docs.iter().enumerate() {
|
||||
let stem = Path::new(&doc.relpath).file_stem().unwrap_or_default().to_string_lossy();
|
||||
for key in [slugify(&doc.name), slugify(&doc.title), slugify(&stem)] {
|
||||
lookup.entry(key).or_insert(i);
|
||||
}
|
||||
}
|
||||
let mut back: Vec<Vec<String>> = vec![Vec::new(); docs.len()];
|
||||
for (i, (doc, _)) in docs.iter().enumerate() {
|
||||
let mut targets: std::collections::BTreeSet<usize> = docs[i]
|
||||
.0
|
||||
.links
|
||||
.iter()
|
||||
.filter_map(|l| lookup.get(&slugify(l)).copied())
|
||||
.collect();
|
||||
targets.remove(&i);
|
||||
for t in targets {
|
||||
back[t].push(doc.name.clone());
|
||||
}
|
||||
}
|
||||
for ((doc, _), b) in docs.iter_mut().zip(back) {
|
||||
doc.backlinks = b;
|
||||
}
|
||||
}
|
||||
|
||||
fn walk(home: &Path, dir: &Path, docs: &mut Vec<(Doc, String)>) -> Result<(), String> {
|
||||
let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let fname = file_name.to_string_lossy();
|
||||
if fname.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
walk(home, &path, docs)?;
|
||||
} else if fname.ends_with(".md") {
|
||||
docs.push(read_doc(home, &path)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
|
||||
let text = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let name = strip_stamp(&stem).to_string();
|
||||
let fm = parse_frontmatter(&text);
|
||||
let relpath = path
|
||||
.strip_prefix(home)
|
||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||
.display()
|
||||
.to_string();
|
||||
let doc = Doc {
|
||||
relpath,
|
||||
title: fm.get("title").unwrap_or(&name).clone(),
|
||||
description: fm.get("description").cloned(),
|
||||
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
|
||||
links: wikilinks(&text),
|
||||
backlinks: Vec::new(),
|
||||
name,
|
||||
};
|
||||
Ok((doc, text))
|
||||
}
|
||||
|
||||
/// Slug-Vergleich eines Wikilink-Ziels gegen Name, Titel und Datei-Stem.
|
||||
fn matches(doc: &Doc, want: &str) -> bool {
|
||||
slugify(&doc.name) == want
|
||||
|| slugify(&doc.title) == want
|
||||
|| Path::new(&doc.relpath)
|
||||
.file_stem()
|
||||
.is_some_and(|s| slugify(&s.to_string_lossy()) == want)
|
||||
}
|
||||
|
||||
/// Übersichts- bzw. Schlagwort-Seite als strukturierte Wiki-Daten: Dokumente
|
||||
/// nach Ordnern gruppiert (neueste zuerst), Schlagwort-Leiste mit Zählern.
|
||||
/// Das Panel rendert daraus die Wiki-Ansicht; `kind` unterscheidet im
|
||||
/// Wiki-Puffer Seite und Dokument.
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiPage {
|
||||
pub(crate) kind: &'static str,
|
||||
pub(crate) home: String,
|
||||
pub(crate) tag: Option<String>,
|
||||
pub(crate) total: usize,
|
||||
pub(crate) tags: Vec<TagCount>,
|
||||
pub(crate) folders: Vec<WikiFolder>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct TagCount {
|
||||
pub(crate) name: String,
|
||||
pub(crate) count: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiFolder {
|
||||
/// Ordner relativ zum Archiv-Home; leer für die Wurzel.
|
||||
pub(crate) name: String,
|
||||
pub(crate) docs: Vec<WikiDocEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiDocEntry {
|
||||
pub(crate) name: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) tags: Vec<String>,
|
||||
/// Archivierungsdatum aus dem Zeitstempel-Stem (`YYYY-MM-DD`).
|
||||
pub(crate) date: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, String> {
|
||||
let docs = scan_archive(home)?;
|
||||
let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
|
||||
for doc in &docs {
|
||||
for t in &doc.tags {
|
||||
*counts.entry(t).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let selected: Vec<&Doc> = match tag {
|
||||
Some(t) => docs.iter().filter(|d| d.tags.iter().any(|x| x == t)).collect(),
|
||||
None => docs.iter().collect(),
|
||||
};
|
||||
let mut folders: std::collections::BTreeMap<String, Vec<&Doc>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for doc in &selected {
|
||||
let folder = Path::new(&doc.relpath)
|
||||
.parent()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
folders.entry(folder).or_default().push(doc);
|
||||
}
|
||||
Ok(WikiPage {
|
||||
kind: "page",
|
||||
home: home.display().to_string(),
|
||||
tag: tag.map(str::to_string),
|
||||
total: selected.len(),
|
||||
tags: counts
|
||||
.into_iter()
|
||||
.map(|(name, count)| TagCount { name: name.to_string(), count })
|
||||
.collect(),
|
||||
folders: folders
|
||||
.into_iter()
|
||||
.map(|(name, mut list)| {
|
||||
// Zeitstempel-Stems sortieren chronologisch — absteigend = neueste oben.
|
||||
list.sort_by(|a, b| b.relpath.cmp(&a.relpath));
|
||||
WikiFolder { name, docs: list.into_iter().map(doc_entry).collect() }
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
||||
let stem = Path::new(&doc.relpath)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let date = (stem != doc.name).then(|| stem[..10].to_string());
|
||||
WikiDocEntry {
|
||||
name: doc.name.clone(),
|
||||
title: doc.title.clone(),
|
||||
description: doc.description.clone(),
|
||||
tags: doc.tags.clone(),
|
||||
date,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Archiv-Dokument als Wiki-Ansicht: Markdown-Rumpf ohne Frontmatter plus
|
||||
/// Metadaten und Backlinks aus dem Scan.
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct WikiDocPage {
|
||||
pub(crate) kind: &'static str,
|
||||
pub(crate) home: String,
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) tags: Vec<String>,
|
||||
pub(crate) backlinks: Vec<String>,
|
||||
pub(crate) markdown: String,
|
||||
}
|
||||
|
||||
pub(crate) fn wiki_doc(home: &Path, target: &str) -> Result<WikiDocPage, String> {
|
||||
let pairs = scan_with_bodies(home)?;
|
||||
let want = slugify(target);
|
||||
let (doc, body) = pairs
|
||||
.iter()
|
||||
.find(|(d, _)| matches(d, &want))
|
||||
.ok_or_else(|| format!("kein Archiv-Dokument zu „{target}“ gefunden"))?;
|
||||
Ok(WikiDocPage {
|
||||
kind: "doc",
|
||||
home: home.display().to_string(),
|
||||
relpath: doc.relpath.clone(),
|
||||
name: doc.name.clone(),
|
||||
title: doc.title.clone(),
|
||||
tags: doc.tags.clone(),
|
||||
backlinks: doc.backlinks.clone(),
|
||||
markdown: strip_frontmatter(body).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Alle `[[ziel]]`-Vorkommen im Text, in Dokumentreihenfolge; bei
|
||||
/// `[[ziel|label]]` zählt nur das Ziel.
|
||||
fn wikilinks(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find("[[") {
|
||||
rest = &rest[start + 2..];
|
||||
let Some(end) = rest.find("]]") else {
|
||||
break;
|
||||
};
|
||||
let inner = &rest[..end];
|
||||
let target = inner.split('|').next().unwrap_or(inner).trim();
|
||||
if !target.is_empty() {
|
||||
out.push(target.to_string());
|
||||
}
|
||||
rest = &rest[end + 2..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::tmp_paths;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn write(home: &Path, rel: &str, content: &str) {
|
||||
let path = home.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
fn archiv() -> PathBuf {
|
||||
let home = tmp_paths().home.join("archiv");
|
||||
fs::create_dir_all(&home).unwrap();
|
||||
write(
|
||||
&home,
|
||||
"2026-07-19_1000-adr-logging.md",
|
||||
"---\ntitle: \"ADR Logging\"\ndescription: \"Logging vereinheitlichen\"\ntags: [\"adr\", \"infra\"]\n---\n\nSiehe [[notiz-deploy|die Deploy-Notiz]].\n",
|
||||
);
|
||||
write(
|
||||
&home,
|
||||
"konzepte/2026-07-19_1005-notiz-deploy.md",
|
||||
"---\ntitle: \"Notiz Deploy\"\n---\n\nText ohne Links.\n",
|
||||
);
|
||||
write(&home, ".versteckt/ignoriert.md", "unsichtbar");
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_liest_baum_frontmatter_und_links() {
|
||||
let home = archiv();
|
||||
let docs = scan_archive(&home).unwrap();
|
||||
assert_eq!(docs.len(), 2);
|
||||
let adr = &docs[0];
|
||||
assert_eq!(adr.name, "adr-logging");
|
||||
assert_eq!(adr.title, "ADR Logging");
|
||||
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
|
||||
assert_eq!(adr.tags, vec!["adr", "infra"]);
|
||||
assert_eq!(adr.links, vec!["notiz-deploy"]);
|
||||
assert_eq!(docs[1].relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backlinks_aus_wikilinks() {
|
||||
let home = archiv();
|
||||
let docs = scan_archive(&home).unwrap();
|
||||
let deploy = docs.iter().find(|d| d.name == "notiz-deploy").unwrap();
|
||||
assert_eq!(deploy.backlinks, vec!["adr-logging"]);
|
||||
let adr = docs.iter().find(|d| d.name == "adr-logging").unwrap();
|
||||
assert!(adr.backlinks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startseite_gruppiert_und_zaehlt() {
|
||||
let home = archiv();
|
||||
let page = archive_page(&home, None).unwrap();
|
||||
assert_eq!(page.kind, "page");
|
||||
assert_eq!(page.tag, None);
|
||||
assert_eq!(page.total, 2);
|
||||
let tags: Vec<(&str, usize)> =
|
||||
page.tags.iter().map(|t| (t.name.as_str(), t.count)).collect();
|
||||
assert_eq!(tags, vec![("adr", 1), ("infra", 1)]);
|
||||
assert_eq!(page.folders.len(), 2);
|
||||
assert_eq!(page.folders[0].name, "");
|
||||
let adr = &page.folders[0].docs[0];
|
||||
assert_eq!(adr.name, "adr-logging");
|
||||
assert_eq!(adr.title, "ADR Logging");
|
||||
assert_eq!(adr.description.as_deref(), Some("Logging vereinheitlichen"));
|
||||
assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
|
||||
assert_eq!(page.folders[1].name, "konzepte");
|
||||
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_seite_filtert() {
|
||||
let home = archiv();
|
||||
let page = archive_page(&home, Some("adr")).unwrap();
|
||||
assert_eq!(page.tag.as_deref(), Some("adr"));
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.folders.len(), 1);
|
||||
assert_eq!(page.folders[0].docs[0].name, "adr-logging");
|
||||
// Die Schlagwort-Leiste bleibt vollständig — sie ist die Navigation.
|
||||
assert_eq!(page.tags.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_doc_mit_rumpf_und_backlinks() {
|
||||
let home = archiv();
|
||||
// Auflösung über Titel; Name und Stem gehen über dieselben Slug-Vergleiche.
|
||||
let doc = wiki_doc(&home, "Notiz Deploy").unwrap();
|
||||
assert_eq!(doc.kind, "doc");
|
||||
assert_eq!(doc.name, "notiz-deploy");
|
||||
assert_eq!(
|
||||
wiki_doc(&home, "2026-07-19_1000-adr-logging").unwrap().name,
|
||||
"adr-logging"
|
||||
);
|
||||
assert_eq!(doc.title, "Notiz Deploy");
|
||||
assert_eq!(doc.relpath, "konzepte/2026-07-19_1005-notiz-deploy.md");
|
||||
assert_eq!(doc.backlinks, vec!["adr-logging"]);
|
||||
assert_eq!(doc.markdown, "Text ohne Links.\n");
|
||||
assert!(wiki_doc(&home, "fehlt").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Volltext-Suche übers Panel-Archiv: SQLite-FTS5-Index, bei jeder Anfrage
|
||||
//! frisch in-memory aus dem Archiv-Baum gebaut. Bei den Archiv-Größen dieser
|
||||
//! App ist der Aufbau Millisekundensache; damit gibt es keinen persistierten
|
||||
//! Index, keine Staleness und nichts, was gesynct werden könnte. Die
|
||||
//! Tool-Schnittstelle bleibt engine-unabhängig.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::domain::archive_index::scan_with_bodies;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub(crate) struct Hit {
|
||||
/// Pfad relativ zum Archiv-Home.
|
||||
pub(crate) relpath: String,
|
||||
pub(crate) title: String,
|
||||
/// Textausschnitt um die Fundstelle, Treffer in `**…**`.
|
||||
pub(crate) snippet: String,
|
||||
}
|
||||
|
||||
/// Durchsucht das Archiv unter `home`. `query` ist FTS5-Syntax (Wörter,
|
||||
/// "Phrasen", Präfix*); `tag` engt auf ein Schlagwort ein. Treffer nach
|
||||
/// BM25-Rang, höchstens `limit`.
|
||||
pub(crate) fn search(
|
||||
home: &Path,
|
||||
query: &str,
|
||||
tag: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Hit>, String> {
|
||||
let conn = build_index(home)?;
|
||||
let expr = match_expr(query, tag)?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \
|
||||
FROM docs WHERE docs MATCH ?1 ORDER BY rank LIMIT ?2",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![expr, limit as i64], |row| {
|
||||
Ok(Hit { relpath: row.get(0)?, title: row.get(1)?, snippet: row.get(2)? })
|
||||
})
|
||||
.map_err(|e| format!("Suchausdruck „{query}“: {e}"))?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// MATCH-Ausdruck aus Query und optionalem Tag-Filter.
|
||||
fn match_expr(query: &str, tag: Option<&str>) -> Result<String, String> {
|
||||
let q = query.trim();
|
||||
let t = tag.map(|t| format!("tags:\"{}\"", t.replace('"', "")));
|
||||
match (q.is_empty(), t) {
|
||||
(false, Some(t)) => Ok(format!("({q}) AND {t}")),
|
||||
(false, None) => Ok(q.to_string()),
|
||||
(true, Some(t)) => Ok(t),
|
||||
(true, None) => Err("leere Suchanfrage".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut den FTS5-Index in-memory aus dem Archiv-Baum.
|
||||
fn build_index(home: &Path) -> Result<Connection, String> {
|
||||
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
conn
|
||||
.execute_batch(
|
||||
"CREATE VIRTUAL TABLE docs USING fts5(relpath UNINDEXED, name, title, description, tags, body)",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let docs = scan_with_bodies(home)?;
|
||||
let mut insert = conn
|
||||
.prepare("INSERT INTO docs (relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
for (doc, body) in &docs {
|
||||
insert
|
||||
.execute(rusqlite::params![
|
||||
doc.relpath,
|
||||
doc.name,
|
||||
doc.title,
|
||||
doc.description.as_deref().unwrap_or(""),
|
||||
doc.tags.join(" "),
|
||||
body,
|
||||
])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
drop(insert);
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::testutil::tmp_paths;
|
||||
use std::fs;
|
||||
|
||||
fn archiv() -> std::path::PathBuf {
|
||||
let home = tmp_paths().home.join("archiv");
|
||||
fs::create_dir_all(home.join("konzepte")).unwrap();
|
||||
fs::write(
|
||||
home.join("2026-07-19_1000-adr-logging.md"),
|
||||
"---\ntitle: \"ADR Logging\"\ntags: [\"adr\", \"infra\"]\n---\n\nStrukturiertes Logging mit tracing vereinheitlichen.\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
home.join("konzepte/2026-07-19_1005-notiz-deploy.md"),
|
||||
"---\ntitle: \"Notiz Deploy\"\ntags: [\"infra\"]\n---\n\nDeploy braucht lsregister auf macOS.\n",
|
||||
)
|
||||
.unwrap();
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn findet_nach_inhaltswort() {
|
||||
let home = archiv();
|
||||
let hits = search(&home, "tracing", None, 10).unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].title, "ADR Logging");
|
||||
assert!(hits[0].snippet.contains("**tracing**"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_filter_engt_ein() {
|
||||
let home = archiv();
|
||||
assert_eq!(search(&home, "", Some("infra"), 10).unwrap().len(), 2);
|
||||
assert_eq!(search(&home, "", Some("adr"), 10).unwrap().len(), 1);
|
||||
let hits = search(&home, "deploy", Some("adr"), 10).unwrap();
|
||||
assert!(hits.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phrase_und_praefix() {
|
||||
let home = archiv();
|
||||
assert_eq!(search(&home, "\"Strukturiertes Logging\"", None, 10).unwrap().len(), 1);
|
||||
assert_eq!(search(&home, "lsregist*", None, 10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leere_anfrage_scheitert() {
|
||||
let home = archiv();
|
||||
assert!(search(&home, " ", None, 10).is_err());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! laufen ausschließlich über crate::platform.
|
||||
|
||||
pub(crate) mod archive;
|
||||
pub(crate) mod archive_index;
|
||||
pub(crate) mod archive_search;
|
||||
pub(crate) mod credentials;
|
||||
pub(crate) mod paths;
|
||||
pub(crate) mod pool;
|
||||
|
||||
@@ -56,6 +56,33 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
|
||||
Paths::real().panels_dir().join(format!("{project}.md"))
|
||||
}
|
||||
|
||||
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
|
||||
/// Session-Start geleert). Der Pfad landet als AI_CONTROL_COMMANDS in der
|
||||
/// PTY-Umgebung.
|
||||
pub(crate) fn commands_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{project}.commands.jsonl"))
|
||||
}
|
||||
|
||||
/// Suchtreffer-Datei eines Projekts (JSON, letzter search_archive-Aufruf —
|
||||
/// flüchtig, wird beim Session-Start geleert). Der Pfad landet als
|
||||
/// AI_CONTROL_SEARCH in der PTY-Umgebung.
|
||||
pub(crate) fn search_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{project}.search.json"))
|
||||
}
|
||||
|
||||
/// Wiki-Puffer eines Projekts (JSON, jeweils letzte Wiki-Seite bzw. letztes
|
||||
/// geöffnetes Dokument — flüchtig, wird beim Session-Start geleert). Der Pfad
|
||||
/// landet als AI_CONTROL_WIKI in der PTY-Umgebung.
|
||||
pub(crate) fn wiki_file(project: &str) -> PathBuf {
|
||||
Paths::real()
|
||||
.panels_dir()
|
||||
.join(format!("{project}.wiki.json"))
|
||||
}
|
||||
|
||||
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
|
||||
pub(crate) fn expand_home(paths: &Paths, p: &str) -> PathBuf {
|
||||
if p == "~" {
|
||||
|
||||
@@ -151,7 +151,7 @@ pub(crate) fn init_pool_config(
|
||||
let mut settings = serde_json::json!({
|
||||
"promptSuggestionEnabled": false,
|
||||
"awaySummaryEnabled": false,
|
||||
"permissions": { "allow": [PANEL_WRITE_PERMISSION] },
|
||||
"permissions": { "allow": PANEL_PERMISSIONS },
|
||||
});
|
||||
let base = settings.as_object_mut().unwrap();
|
||||
if let Some(obj) = extra.as_object() {
|
||||
@@ -180,9 +180,12 @@ const PANEL_SKILL: &str = include_str!("../../resources/panel-skill.md");
|
||||
/// („text panel"). Bestimmt den Tool-Namespace `mcp__<key>__<tool>`.
|
||||
const PANEL_MCP_SERVER: &str = "text-panel";
|
||||
|
||||
/// Freigabe für das MCP-Tool `write_panel` — damit der Aufruf ohne Rückfrage
|
||||
/// läuft. Namensschema: `mcp__<server>__<tool>`.
|
||||
const PANEL_WRITE_PERMISSION: &str = "mcp__text-panel__write_panel";
|
||||
/// Freigaben für die Panel-MCP-Tools — damit die Aufrufe ohne Rückfrage
|
||||
/// laufen. Namensschema: `mcp__<server>__<tool>`.
|
||||
const PANEL_PERMISSIONS: [&str; 2] = [
|
||||
"mcp__text-panel__write_panel",
|
||||
"mcp__text-panel__write_commands",
|
||||
];
|
||||
|
||||
/// Schreibt/aktualisiert die Panel-Skill-Datei in einem Pool (überschreibt eine
|
||||
/// evtl. ältere, tee-basierte Fassung).
|
||||
@@ -223,13 +226,14 @@ fn ensure_panel_permission(pool_dir: &std::path::Path) {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let before = allow.len();
|
||||
let before = allow.clone();
|
||||
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
|
||||
let has = allow.iter().any(|e| e.as_str() == Some(PANEL_WRITE_PERMISSION));
|
||||
if !has {
|
||||
allow.push(serde_json::json!(PANEL_WRITE_PERMISSION));
|
||||
for perm in PANEL_PERMISSIONS {
|
||||
if !allow.iter().any(|e| e.as_str() == Some(perm)) {
|
||||
allow.push(serde_json::json!(perm));
|
||||
}
|
||||
}
|
||||
if has && allow.len() == before {
|
||||
if *allow == before {
|
||||
return; // nichts geändert
|
||||
}
|
||||
if let Ok(out) = serde_json::to_string_pretty(&v) {
|
||||
|
||||
@@ -29,13 +29,16 @@ pub(crate) struct ProjectConfig {
|
||||
pub(crate) pool: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
/// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert).
|
||||
/// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen
|
||||
/// (~-relativ gespeichert). Liest den früheren Key `archiveDir` weiterhin ein,
|
||||
/// geschrieben wird nur noch `archiveHome`.
|
||||
#[serde(
|
||||
default,
|
||||
rename = "archiveDir",
|
||||
rename = "archiveHome",
|
||||
alias = "archiveDir",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub(crate) archive_dir: Option<String>,
|
||||
pub(crate) archive_home: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
@@ -223,7 +226,7 @@ pub(crate) fn create_project_full_in(
|
||||
|
||||
reg.insert(name.to_string(), dir.clone());
|
||||
save_registry(paths, ®)?;
|
||||
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None };
|
||||
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_home: None };
|
||||
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
||||
write_project_config_in(paths, name, &cfg)?;
|
||||
}
|
||||
@@ -431,7 +434,7 @@ pub(crate) fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result
|
||||
pub(crate) fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
|
||||
let mut cfg = read_project_config_in(paths, project)?;
|
||||
cfg.pool = None;
|
||||
if cfg.terminal.is_empty() && cfg.archive_dir.is_none() {
|
||||
if cfg.terminal.is_empty() && cfg.archive_home.is_none() {
|
||||
let cfg_path = project_config_path(paths, project)?;
|
||||
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
|
||||
}
|
||||
|
||||
+298
-37
@@ -66,35 +66,142 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
||||
Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \
|
||||
ab, wo er mit der Maus selektierbar und über einen Button kopierbar \
|
||||
ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
|
||||
aufrufen und im Chat nur kurz bestätigen.",
|
||||
aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \
|
||||
IMMER `path` statt `text` übergeben — der Server liest die Datei \
|
||||
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Vollständiger Entwurf als Markdown-Rohtext.",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description":
|
||||
"Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt ins \
|
||||
Panel geladen wird. Genau eines von beiden angeben.",
|
||||
}
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "write_commands",
|
||||
"description":
|
||||
"Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \
|
||||
Kacheln im ai-control-Panel. IMMER nutzen, wenn ein Befehl für den \
|
||||
Nutzer bestimmt ist — statt ihn als Codeblock in den Chat zu \
|
||||
schreiben; im Chat nur kurz einordnen. Die Befehle werden an die \
|
||||
Befehls-History der Session angehängt (flüchtig, startet mit \
|
||||
jeder Session leer).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"description": "Befehle in Ausführungsreihenfolge.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cmd": {
|
||||
"type": "string",
|
||||
"description": "Der Befehl, exakt so ausführbar.",
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"description": "Optionale Kurznotiz, was der Befehl tut.",
|
||||
}
|
||||
},
|
||||
"required": ["cmd"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["commands"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "show_commands",
|
||||
"description":
|
||||
"Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \
|
||||
allen bisher ausgegebenen Befehlen), ohne etwas anzuhängen. Nutzen, \
|
||||
wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \
|
||||
„zeige die Befehlsliste“).",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
},
|
||||
{
|
||||
"name": "archive_panel",
|
||||
"description":
|
||||
"Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \
|
||||
Markdown-Datei im Archiv-Ordner des Projekts. Auf Wunsch nutzen \
|
||||
(Nutzer sagt etwa „archiviere das“). Ist kein Ordner konfiguriert, \
|
||||
den Zielpfad im \
|
||||
Argument `dir` mitgeben (der Nutzer nennt ihn); ohne `dir` und ohne \
|
||||
konfigurierten Ordner meldet das Tool das zurück.",
|
||||
Markdown-Datei im Archiv-Home des Projekts. Auf Wunsch nutzen \
|
||||
(Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \
|
||||
`description` und `tags` mitgeben — einmalige Kuratierung im Moment \
|
||||
des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \
|
||||
konfiguriert, den Zielpfad im Argument `dir` mitgeben (der Nutzer \
|
||||
nennt ihn); ohne `dir` und ohne konfiguriertes Home meldet das Tool \
|
||||
das zurück.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dir": {
|
||||
"type": "string",
|
||||
"description":
|
||||
"Optionaler Archiv-Ordner. Wird gesetzt und für künftige \
|
||||
Archivierungen gemerkt.",
|
||||
"Optionales Archiv-Home (absoluter Pfad). Wird gesetzt und für \
|
||||
künftige Archivierungen gemerkt.",
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description":
|
||||
"Optionaler Unterordner im Archiv-Home, relativ (z. B. \
|
||||
`konzepte/panel`). Wird angelegt.",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Einzeiler zum Inhalt fürs Frontmatter.",
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Schlagwörter fürs Frontmatter (kurze Slugs).",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "show_archive",
|
||||
"description":
|
||||
"Zeigt die Archiv-Übersicht des Projekts als Wiki-Seite im Panel: \
|
||||
Dokumente nach Ordnern gruppiert, mit Beschreibungen und \
|
||||
klickbaren Schlagwort-Links. Mit `tag` stattdessen die Seite eines \
|
||||
Schlagworts. Nutzen, wenn der Nutzer das Archiv sehen will \
|
||||
(„zeig das Archiv“).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Optional: Seite dieses Schlagworts statt der Übersicht.",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_archive",
|
||||
"description":
|
||||
"Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \
|
||||
Wörter, \"Phrasen\", Präfix*). Die Treffer erscheinen als Kacheln \
|
||||
im Panel; das Tool liefert sie zusätzlich mit Pfad und Snippet \
|
||||
zurück. Nutzen, wenn der Nutzer im Archiv suchen will („such im \
|
||||
Archiv nach …“).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Suchanfrage (FTS5-Syntax).",
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Optional: auf ein Schlagwort einengen.",
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -110,44 +217,198 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
|
||||
fn call_tool(req: &Value) -> Value {
|
||||
match req["params"]["name"].as_str().unwrap_or("") {
|
||||
"write_panel" => call_write(req),
|
||||
"write_commands" => call_write_commands(req),
|
||||
"show_commands" => call_show_commands(),
|
||||
"archive_panel" => call_archive(req),
|
||||
other => json!({
|
||||
"content": [{ "type": "text", "text": format!("Unbekanntes Tool: {other}") }],
|
||||
"isError": true,
|
||||
}),
|
||||
"search_archive" => call_search(req),
|
||||
"show_archive" => call_show_archive(req),
|
||||
other => err(format!("Unbekanntes Tool: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Erfolgs-Antwort eines Tool-Aufrufs (ein Text-Content).
|
||||
fn ok(text: String) -> Value {
|
||||
json!({ "content": [{ "type": "text", "text": text }] })
|
||||
}
|
||||
|
||||
/// Fehler-Antwort eines Tool-Aufrufs.
|
||||
fn err(text: String) -> Value {
|
||||
json!({ "content": [{ "type": "text", "text": text }], "isError": true })
|
||||
}
|
||||
|
||||
/// Pfad aus der PTY-Umgebung; fehlt die Variable, läuft das Terminal
|
||||
/// außerhalb von ai-control.
|
||||
fn env_path(var: &str) -> Result<String, Value> {
|
||||
std::env::var(var)
|
||||
.map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control).")))
|
||||
}
|
||||
|
||||
/// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL.
|
||||
fn write_panel_text(text: &str) -> Result<(), Value> {
|
||||
let path = env_path("AI_CONTROL_PANEL")?;
|
||||
std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}")))
|
||||
}
|
||||
|
||||
fn call_write(req: &Value) -> Value {
|
||||
let text = req["params"]["arguments"]["text"].as_str().unwrap_or("");
|
||||
match std::env::var("AI_CONTROL_PANEL") {
|
||||
Ok(path) => match std::fs::write(&path, text) {
|
||||
Ok(()) => json!({ "content": [{ "type": "text", "text": "Entwurf ins Panel geschrieben." }] }),
|
||||
Err(e) => json!({
|
||||
"content": [{ "type": "text", "text": format!("Panel-Datei nicht schreibbar: {e}") }],
|
||||
"isError": true,
|
||||
}),
|
||||
let args = &req["params"]["arguments"];
|
||||
// `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne
|
||||
// dass das Modell den Inhalt Token für Token als `text` generieren muss.
|
||||
let (text, ok_msg) = match args["path"].as_str() {
|
||||
Some(src) => match std::fs::read_to_string(src) {
|
||||
Ok(content) => (content, "Datei ins Panel geladen."),
|
||||
Err(e) => return err(format!("Datei {src} nicht lesbar: {e}")),
|
||||
},
|
||||
Err(_) => json!({
|
||||
"content": [{ "type": "text", "text": "AI_CONTROL_PANEL nicht gesetzt (Terminal außerhalb ai-control)." }],
|
||||
"isError": true,
|
||||
}),
|
||||
None => (
|
||||
args["text"].as_str().unwrap_or("").to_string(),
|
||||
"Entwurf ins Panel geschrieben.",
|
||||
),
|
||||
};
|
||||
match write_panel_text(&text) {
|
||||
Ok(()) => ok(ok_msg.into()),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merkt, ob dieser Server-Prozess (= eine claude-Session) schon einen
|
||||
/// Session-Marker in die History geschrieben hat.
|
||||
static SESSION_MARKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn call_write_commands(req: &Value) -> Value {
|
||||
let commands = req["params"]["arguments"]["commands"].clone();
|
||||
let count = commands.as_array().map(Vec::len).unwrap_or(0);
|
||||
let path = match env_path("AI_CONTROL_COMMANDS") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
// Ein Record pro Aufruf; der erste Aufruf der Session bekommt einen
|
||||
// Marker-Record davor (Session-Trenner in der Kachel-Ansicht).
|
||||
let mut out = String::new();
|
||||
if !SESSION_MARKED.swap(true, std::sync::atomic::Ordering::SeqCst) {
|
||||
out.push_str(&json!({ "ts": ts, "session": true }).to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&json!({ "ts": ts, "commands": commands }).to_string());
|
||||
out.push('\n');
|
||||
let res = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.and_then(|mut f| std::io::Write::write_all(&mut f, out.as_bytes()));
|
||||
match res {
|
||||
Ok(()) => ok(format!("{count} Befehl(e) als Kacheln im Panel abgelegt.")),
|
||||
Err(e) => err(format!("Command-History nicht schreibbar: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet die Kachel-Ansicht, ohne der History etwas hinzuzufügen: mtime der
|
||||
/// History-Datei anfassen genügt — der Watcher im Terminal-Prozess meldet sie
|
||||
/// als `commands-update`, das Panel schaltet auf „Befehle“ um.
|
||||
fn call_show_commands() -> Value {
|
||||
let path = match env_path("AI_CONTROL_COMMANDS") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let res = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.and_then(|f| f.set_modified(std::time::SystemTime::now()));
|
||||
match res {
|
||||
Ok(()) => ok("Befehls-History im Panel geöffnet.".into()),
|
||||
Err(e) => err(format!("Command-History nicht erreichbar: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn call_archive(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let dir = req["params"]["arguments"]["dir"].as_str();
|
||||
match crate::domain::archive::archive_panel_content(&project, dir) {
|
||||
Ok(path) => json!({
|
||||
"content": [{ "type": "text", "text": format!("Archiviert: {}", path.display()) }],
|
||||
}),
|
||||
Err(e) => json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."),
|
||||
}],
|
||||
"isError": true,
|
||||
}),
|
||||
let args = &req["params"]["arguments"];
|
||||
let dir = args["dir"].as_str();
|
||||
let meta = crate::domain::archive::ArchiveMeta {
|
||||
folder: args["folder"].as_str().map(str::to_string),
|
||||
description: args["description"].as_str().map(str::to_string),
|
||||
tags: args["tags"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
match crate::domain::archive::archive_panel_content(&project, dir, &meta) {
|
||||
Ok(path) => ok(format!("Archiviert: {}", path.display())),
|
||||
Err(e) => err(format!(
|
||||
"Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Archiv-Übersicht bzw. Schlagwort-Seite generieren und in den Wiki-Puffer
|
||||
/// schreiben — der Watcher zieht sie als Wiki-Ansicht ins Panel.
|
||||
fn call_show_archive(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let home = match crate::domain::archive::require_archive_home(&project) {
|
||||
Ok(home) => home,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let tag = req["params"]["arguments"]["tag"].as_str();
|
||||
let page = match crate::domain::archive_index::archive_page(&home, tag) {
|
||||
Ok(page) => page,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let path = match env_path("AI_CONTROL_WIKI") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let json = match serde_json::to_string(&page) {
|
||||
Ok(json) => json,
|
||||
Err(e) => return err(e.to_string()),
|
||||
};
|
||||
match std::fs::write(&path, json) {
|
||||
Ok(()) => ok(match tag {
|
||||
Some(t) => format!("Schlagwort-Seite #{t} im Panel."),
|
||||
None => "Archiv-Übersicht im Panel.".to_string(),
|
||||
}),
|
||||
Err(e) => err(format!("Wiki-Datei nicht schreibbar: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Volltext-Suche übers Archiv: Treffer als JSON in die Suchtreffer-Datei
|
||||
/// (der Watcher zieht sie als Kacheln ins Panel) und als Text zurück an claude.
|
||||
fn call_search(req: &Value) -> Value {
|
||||
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
|
||||
let home = match crate::domain::archive::require_archive_home(&project) {
|
||||
Ok(home) => home,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let args = &req["params"]["arguments"];
|
||||
let query = args["query"].as_str().unwrap_or("");
|
||||
let tag = args["tag"].as_str();
|
||||
let hits = match crate::domain::archive_search::search(&home, query, tag, 20) {
|
||||
Ok(hits) => hits,
|
||||
Err(e) => return err(format!("Suche fehlgeschlagen: {e}")),
|
||||
};
|
||||
let search_path = match env_path("AI_CONTROL_SEARCH") {
|
||||
Ok(path) => path,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let payload = json!({
|
||||
"query": query,
|
||||
"tag": tag,
|
||||
"home": home.display().to_string(),
|
||||
"hits": hits,
|
||||
});
|
||||
if let Err(e) = std::fs::write(&search_path, payload.to_string()) {
|
||||
return err(format!("Suchtreffer-Datei nicht schreibbar: {e}"));
|
||||
}
|
||||
let list: Vec<String> = hits
|
||||
.iter()
|
||||
.map(|h| format!("- {} — {} ({})", h.relpath, h.title, h.snippet))
|
||||
.collect();
|
||||
ok(format!(
|
||||
"{} Treffer, als Kacheln im Panel. Archiv: {}\n{}",
|
||||
hits.len(),
|
||||
home.display(),
|
||||
list.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
+125
-14
@@ -5,7 +5,7 @@ use std::io::{Read, Write};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
use crate::domain::paths::{panel_file, Paths};
|
||||
use crate::domain::paths::{commands_file, panel_file, search_file, wiki_file, Paths};
|
||||
use crate::domain::project::{project_pool_dir, terminal_config, TerminalConfig};
|
||||
use crate::domain::registry::project_dir;
|
||||
use crate::domain::settings::claude_command;
|
||||
@@ -116,10 +116,28 @@ pub fn term_start(
|
||||
}
|
||||
let _ = std::fs::write(&panel_path, "");
|
||||
|
||||
// Command-Kanal: History-Datei (JSONL) leeren — die Befehls-History ist
|
||||
// flüchtig und gilt nur für diese Session; write_commands hängt Records an.
|
||||
let commands_path = commands_file(&project);
|
||||
let _ = std::fs::write(&commands_path, "");
|
||||
|
||||
// Such-Kanal: Treffer-Datei leeren — search_archive schreibt den jeweils
|
||||
// letzten Suchlauf hinein, der Watcher zieht ihn als Kacheln ins Panel.
|
||||
let search_path = search_file(&project);
|
||||
let _ = std::fs::write(&search_path, "");
|
||||
|
||||
// Wiki-Kanal: Puffer leeren — show_archive und wiki_open schreiben die
|
||||
// jeweils aktuelle Wiki-Seite (JSON) hinein.
|
||||
let wiki_path = wiki_file(&project);
|
||||
let _ = std::fs::write(&wiki_path, "");
|
||||
|
||||
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
|
||||
cmd.cwd(&cwd);
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("AI_CONTROL_PANEL", &panel_path);
|
||||
cmd.env("AI_CONTROL_COMMANDS", &commands_path);
|
||||
cmd.env("AI_CONTROL_SEARCH", &search_path);
|
||||
cmd.env("AI_CONTROL_WIKI", &wiki_path);
|
||||
cmd.env("AI_CONTROL_PROJECT", &project);
|
||||
if let Some(pool_dir) = project_pool_dir(&project)? {
|
||||
cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
|
||||
@@ -177,7 +195,14 @@ pub fn term_start(
|
||||
let _ = app.emit_to(&label, "pty-exit", ());
|
||||
});
|
||||
|
||||
spawn_panel_watcher(window.app_handle().clone(), panel_path);
|
||||
spawn_file_watcher(window.app_handle().clone(), panel_path, "panel-update");
|
||||
spawn_file_watcher(
|
||||
window.app_handle().clone(),
|
||||
commands_path,
|
||||
"commands-update",
|
||||
);
|
||||
spawn_file_watcher(window.app_handle().clone(), search_path, "search-update");
|
||||
spawn_file_watcher(window.app_handle().clone(), wiki_path, "wiki-update");
|
||||
|
||||
terminals.0.lock().unwrap().insert(
|
||||
window.label().to_string(),
|
||||
@@ -186,12 +211,12 @@ pub fn term_start(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Beobachtet die Panel-Datei des Projekts und schickt neuen Inhalt als
|
||||
/// `panel-update` an alle Fenster dieses Terminal-Prozesses (angedocktes Panel
|
||||
/// und ein evtl. abgelöstes Panel-Fenster). Pollt per mtime — kein notify-Crate,
|
||||
/// die Datei ändert sich nur, wenn ein Entwurf geschrieben wird. Der Thread
|
||||
/// endet mit dem Prozess (Fenster zu).
|
||||
fn spawn_panel_watcher(app: AppHandle, path: std::path::PathBuf) {
|
||||
/// Beobachtet eine Panel-Datei des Projekts (Entwurf oder Command-History)
|
||||
/// und schickt neuen Inhalt unter `event` an alle Fenster dieses
|
||||
/// Terminal-Prozesses (angedocktes Panel und ein evtl. abgelöstes
|
||||
/// Panel-Fenster). Pollt per mtime — kein notify-Crate, die Datei ändert sich
|
||||
/// nur, wenn geschrieben wird. Der Thread endet mit dem Prozess (Fenster zu).
|
||||
fn spawn_file_watcher(app: AppHandle, path: std::path::PathBuf, event: &'static str) {
|
||||
std::thread::spawn(move || {
|
||||
let mtime = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
|
||||
let mut last = mtime();
|
||||
@@ -201,7 +226,7 @@ fn spawn_panel_watcher(app: AppHandle, path: std::path::PathBuf) {
|
||||
if now != last {
|
||||
last = now;
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
let _ = app.emit("panel-update", content);
|
||||
let _ = app.emit(event, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,6 +239,40 @@ pub fn panel_read(project: String) -> String {
|
||||
std::fs::read_to_string(panel_file(&project)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Aktuelle Command-History (JSONL; Erstbefüllung der Kachel-Ansicht).
|
||||
#[tauri::command]
|
||||
pub fn commands_read(project: String) -> String {
|
||||
std::fs::read_to_string(commands_file(&project)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Entfernt einen Befehl aus der Command-History (Löschen einer Kachel im
|
||||
/// Panel). `line` ist der Index der nicht-leeren JSONL-Zeile, `entry` der
|
||||
/// Index im commands-Array des Records; ein leer gewordener Record fällt mit
|
||||
/// weg. Der Watcher meldet den neuen Stand als `commands-update`.
|
||||
#[tauri::command]
|
||||
pub fn commands_delete(project: String, line: usize, entry: usize) -> Result<(), String> {
|
||||
let path = commands_file(&project);
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
let mut records: Vec<serde_json::Value> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| serde_json::from_str(l).map_err(|e| e.to_string()))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let cmds = records[line]["commands"]
|
||||
.as_array_mut()
|
||||
.ok_or("Record ohne commands")?;
|
||||
cmds.remove(entry);
|
||||
if cmds.is_empty() {
|
||||
records.remove(line);
|
||||
}
|
||||
let mut out = String::new();
|
||||
for rec in &records {
|
||||
out.push_str(&rec.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
std::fs::write(&path, out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Schreibt den Panel-Inhalt (Titel-Edit im Panel). Der Watcher meldet die
|
||||
/// Änderung als `panel-update` an alle Fenster.
|
||||
#[tauri::command]
|
||||
@@ -221,6 +280,53 @@ pub fn panel_set(project: String, text: String) -> Result<(), String> {
|
||||
std::fs::write(panel_file(&project), text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Letzter Suchlauf (JSON; Erstbefüllung der Treffer-Ansicht).
|
||||
#[tauri::command]
|
||||
pub fn search_read(project: String) -> String {
|
||||
std::fs::read_to_string(search_file(&project)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive und
|
||||
/// schreibt die Treffer-Datei; der Watcher zieht sie in die Ansicht (beide
|
||||
/// Fenster).
|
||||
#[tauri::command]
|
||||
pub fn search_run(project: String, query: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let hits = crate::domain::archive_search::search(&home, &query, None, 20)?;
|
||||
let payload = serde_json::json!({
|
||||
"query": query,
|
||||
"tag": null,
|
||||
"home": home.display().to_string(),
|
||||
"hits": hits,
|
||||
});
|
||||
std::fs::write(search_file(&project), payload.to_string()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Aktueller Wiki-Puffer (JSON; Erstbefüllung der Wiki-Ansicht).
|
||||
#[tauri::command]
|
||||
pub fn wiki_read(project: String) -> String {
|
||||
std::fs::read_to_string(wiki_file(&project)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Öffnet ein Wiki-Ziel (Klick auf einen `[[…]]`-Link oder Suchtreffer):
|
||||
/// `tag:x` → Schlagwort-Seite, `tag:` → Archiv-Übersicht, sonst
|
||||
/// Dokument-Auflösung über den Index. Der `tag:`-Namensraum ist damit dort
|
||||
/// interpretiert, wo archive_page ihn erzeugt — nicht im Frontend. Schreibt
|
||||
/// den Wiki-Puffer; der Watcher meldet ihn als `wiki-update`.
|
||||
#[tauri::command]
|
||||
pub fn wiki_open(project: String, name: String) -> Result<(), String> {
|
||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||
let json = match name.strip_prefix("tag:") {
|
||||
Some(tag) => serde_json::to_string(&crate::domain::archive_index::archive_page(
|
||||
&home,
|
||||
(!tag.is_empty()).then_some(tag),
|
||||
)?),
|
||||
None => serde_json::to_string(&crate::domain::archive_index::wiki_doc(&home, &name)?),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(wiki_file(&project), json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach
|
||||
/// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus.
|
||||
#[tauri::command]
|
||||
@@ -233,16 +339,21 @@ pub fn open_panel_window(app: AppHandle, project: String) -> Result<(), String>
|
||||
let cfg = terminal_config(&project)?;
|
||||
let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default());
|
||||
let title = cfg.title.as_deref().unwrap_or(project.as_str());
|
||||
WebviewWindowBuilder::new(
|
||||
let builder = WebviewWindowBuilder::new(
|
||||
&app,
|
||||
&label,
|
||||
WebviewUrl::App(format!("panel.html?project={project}").into()),
|
||||
)
|
||||
.title(format!("{title} — Entwurf"))
|
||||
.title(format!("{title} — Dokument"))
|
||||
.inner_size(480.0, 640.0)
|
||||
.background_color(tauri::window::Color(r, g, b, 0xff))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
.background_color(tauri::window::Color(r, g, b, 0xff));
|
||||
|
||||
// Linux/GNOME: keine GTK-Deko — eigene Kopfleiste in panel.html, wie beim
|
||||
// Terminal-Fenster.
|
||||
#[cfg(target_os = "linux")]
|
||||
let builder = builder.decorations(false);
|
||||
|
||||
builder.build().map_err(|e| e.to_string())?;
|
||||
app.emit("panel-detached", ()).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user