Refinement-Runde Panel/Wiki: Bedienung, Themes, Archiv-Wahl, Frontend-Tests

- Slug transliteriert Umlaute/ß (fuer statt f-r); aktiver Tab mit Akzent +
  Unterstrich; kein aktiver Tab bei zugeklapptem Panel (mode.clear)
- Suche: Live-Update auch beim Löschen (<3 Zeichen: Hinweis), #tag-Filter in
  der Query, Treffer öffnen im Dokument-Tab (panel_load, ohne Frontmatter);
  Wiki-Sprung-Button im Dokument; Wiki-Fehler sichtbar als Toast
- Archiv-Formular am Archiv-Button (Ordner/Beschreibung/Schlagwörter);
  panel_archive_cmd nimmt Meta entgegen
- Wiki-Übersicht: Zuletzt-Sektion, Backlink-Zähler, Wurzel-Eyebrow
- Themes: Panel-Farben über CSS-Variablen, applyTheme leitet sie aus dem
  jeweiligen Theme ab (auch helle); gemeinsames themes.ts
- Fenster: Doppelklick maximiert; gelöstes Fenster merkt Größe/Position
  (Window-ACL: set-position/outer-position/inner-size ergänzt);
  open_panel_window async (GTK-Deadlock-Vorgabe); Panel-Fenster spiegelt
  JS-Fehler ins Dev-Log
- Archiv pro Projekt wähl-/abwählbar (Einstellungsdialog, setzt/entfernt
  Permissions); require_archive_home legt den Ordner an; ohne Archiv nur
  Befehle+Dokument im Panel
- Frontend-Tests: vitest + happy-dom, 27 Tests über Modus-, Such-, Wiki-,
  Editor- und Formular-Logik (npm test)
This commit is contained in:
marcus hinz
2026-07-19 17:08:05 +02:00
parent 81a663e020
commit 286513def8
27 changed files with 1653 additions and 190 deletions
+458
View File
@@ -28,8 +28,10 @@
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"happy-dom": "^20.11.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.1", "vite": "^8.1.1",
"vitest": "^4.1.10",
"vue-tsc": "^3.3.5" "vue-tsc": "^3.3.5"
} }
}, },
@@ -482,6 +484,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.11.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
@@ -756,6 +765,31 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.2", "version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
@@ -766,6 +800,23 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.7", "version": "6.0.7",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
@@ -783,6 +834,129 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": { "node_modules/@volar/language-core": {
"version": "2.4.28", "version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
@@ -994,6 +1168,46 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1022,12 +1236,29 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1097,6 +1328,25 @@
"node": ">8.0.0" "node": ">8.0.0"
} }
}, },
"node_modules/happy-dom": {
"version": "20.11.0",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.0.tgz",
"integrity": "sha512-XogN4asPd1a56di9prVG6bZxteNcXsZxxKmAvcEfc5Px5Ca2hMyMgk8wvqK2K1V8zXg40j9VANXsDaJYh9DeNA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1416,6 +1666,20 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/obug": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/opentype.js": { "node_modules/opentype.js": {
"version": "0.8.0", "version": "0.8.0",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.8.0.tgz", "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.8.0.tgz",
@@ -1435,6 +1699,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1525,6 +1796,13 @@
"@rolldown/binding-win32-x64-msvc": "1.1.4" "@rolldown/binding-win32-x64-msvc": "1.1.4"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1534,12 +1812,43 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/tiny-inflate": { "node_modules/tiny-inflate": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1557,6 +1866,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1664,6 +1983,96 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vscode-uri": { "node_modules/vscode-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -1730,6 +2139,55 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+3
View File
@@ -7,6 +7,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
@@ -30,8 +31,10 @@
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"happy-dom": "^20.11.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.1", "vite": "^8.1.1",
"vitest": "^4.1.10",
"vue-tsc": "^3.3.5" "vue-tsc": "^3.3.5"
} }
} }
+54 -29
View File
@@ -4,12 +4,27 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Dokument</title> <title>Dokument</title>
<style> <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, html,
body { body {
height: 100%; height: 100%;
margin: 0; margin: 0;
background: #1e1e2e; background: var(--bg);
color: #cdd6f4; color: var(--text);
overflow: hidden; overflow: hidden;
} }
body { body {
@@ -19,7 +34,7 @@
/* Feiner Fensterrahmen fürs dekorationslose Fenster (nur Linux). */ /* Feiner Fensterrahmen fürs dekorationslose Fenster (nur Linux). */
:root[data-platform="other"] body { :root[data-platform="other"] body {
box-sizing: border-box; box-sizing: border-box;
border: 1px solid #313244; border: 1px solid var(--line);
} }
.panel-head { .panel-head {
flex: none; flex: none;
@@ -27,8 +42,8 @@
flex-direction: column; flex-direction: column;
gap: 3px; gap: 3px;
padding: 7px 8px 9px 14px; padding: 7px 8px 9px 14px;
background: #181825; background: var(--surface);
border-bottom: 1px solid #313244; border-bottom: 1px solid var(--line);
font: font:
500 12px/1 -apple-system, 500 12px/1 -apple-system,
system-ui, system-ui,
@@ -50,8 +65,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding-left: 14px; padding-left: 14px;
background: #181825; background: var(--surface);
border-bottom: 1px solid #313244; border-bottom: 1px solid var(--line);
font: font:
500 13px/1 -apple-system, 500 13px/1 -apple-system,
system-ui, system-ui,
@@ -85,14 +100,14 @@
opacity: 0.8; opacity: 0.8;
} }
.winbtn:hover { .winbtn:hover {
background: #313244; background: var(--line);
} }
.winbtn:hover svg { .winbtn:hover svg {
opacity: 1; opacity: 1;
} }
.winbtn.close:hover { .winbtn.close:hover {
background: #f38ba8; background: var(--err);
color: #11111b; color: var(--tile);
} }
/* macOS hat native Fensterknöpfe — eigene Min/Max dort ausblenden. */ /* macOS hat native Fensterknöpfe — eigene Min/Max dort ausblenden. */
:root[data-platform="mac"] .winbtn.osctl { :root[data-platform="mac"] .winbtn.osctl {
@@ -172,7 +187,7 @@
.tab-sep { .tab-sep {
width: 1px; width: 1px;
height: 16px; height: 16px;
background: #45475a; background: var(--line-strong);
margin: 0 6px; margin: 0 6px;
align-self: center; align-self: center;
} }
@@ -188,7 +203,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: #a6adc8; color: var(--muted);
} }
.panel-title[contenteditable="true"] { .panel-title[contenteditable="true"] {
overflow: visible; overflow: visible;
@@ -200,7 +215,7 @@
.panel-btn { .panel-btn {
border: none; border: none;
background: transparent; background: transparent;
color: #a6adc8; color: var(--muted);
cursor: default; cursor: default;
border-radius: 6px; border-radius: 6px;
padding: 6px 9px; padding: 6px 9px;
@@ -209,14 +224,14 @@
align-items: center; align-items: center;
} }
.panel-btn:hover { .panel-btn:hover {
background: #313244; background: var(--line);
color: #cdd6f4; color: var(--text);
} }
.panel-btn.copied { .panel-btn.copied {
color: #a6e3a1; color: var(--ok);
} }
.panel-btn.error { .panel-btn.error {
color: #f38ba8; color: var(--err);
} }
.panel-btn svg { .panel-btn svg {
display: block; display: block;
@@ -227,11 +242,18 @@
stroke-linejoin: round; stroke-linejoin: round;
} }
.panel-btn.active { .panel-btn.active {
background: #313244; background: var(--line);
color: #cdd6f4; 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 { .panel-btn.changed {
color: #f9e2af; color: var(--warn);
} }
.panel-editor { .panel-editor {
flex: 1; flex: 1;
@@ -258,7 +280,7 @@
-webkit-appearance: none; -webkit-appearance: none;
margin-right: auto; margin-right: auto;
background: transparent; background: transparent;
color: #a6adc8; color: var(--muted);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
padding: 6px 9px; padding: 6px 9px;
@@ -266,8 +288,8 @@
cursor: default; cursor: default;
} }
.panel-lang:hover { .panel-lang:hover {
background: #313244; background: var(--line);
color: #cdd6f4; color: var(--text);
} }
#panel-content { #panel-content {
flex: 1; flex: 1;
@@ -284,7 +306,7 @@
width: 8px; width: 8px;
} }
#panel-content::-webkit-scrollbar-thumb { #panel-content::-webkit-scrollbar-thumb {
background: #313244; background: var(--line);
border-radius: 4px; border-radius: 4px;
} }
#panel-content.raw { #panel-content.raw {
@@ -321,12 +343,12 @@
#panel-content.md code { #panel-content.md code {
font-family: "JetBrains Mono", Menlo, monospace; font-family: "JetBrains Mono", Menlo, monospace;
font-size: 0.9em; font-size: 0.9em;
background: #313244; background: var(--line);
border-radius: 4px; border-radius: 4px;
padding: 1px 5px; padding: 1px 5px;
} }
#panel-content.md pre { #panel-content.md pre {
background: #11111b; background: var(--tile);
border-radius: 8px; border-radius: 8px;
padding: 12px 14px; padding: 12px 14px;
overflow: auto; overflow: auto;
@@ -336,12 +358,12 @@
padding: 0; padding: 0;
} }
#panel-content.md blockquote { #panel-content.md blockquote {
border-left: 3px solid #313244; border-left: 3px solid var(--line);
padding-left: 12px; padding-left: 12px;
color: #a6adc8; color: var(--muted);
} }
#panel-content.md a { #panel-content.md a {
color: #89b4fa; color: var(--accent);
} }
.panel-btn[hidden], .panel-btn[hidden],
.panel-lang[hidden], .panel-lang[hidden],
@@ -388,6 +410,9 @@
<option value="it">IT</option> <option value="it">IT</option>
<option value="nl">NL</option> <option value="nl">NL</option>
</select> </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-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)"> <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> <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>
+3
View File
@@ -12,6 +12,9 @@
"core:default", "core:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-set-size", "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-minimize",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"core:window:allow-close", "core:window:allow-close",
+3
View File
@@ -308,11 +308,14 @@ pub(crate) fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
terminal::panel_set, terminal::panel_set,
terminal::search_read, terminal::search_read,
terminal::search_run, terminal::search_run,
terminal::panel_load,
terminal::wiki_read, terminal::wiki_read,
terminal::wiki_open, terminal::wiki_open,
terminal::open_panel_window, terminal::open_panel_window,
commands::panel_archive_dir_cmd, commands::panel_archive_dir_cmd,
commands::panel_archive_cmd, commands::panel_archive_cmd,
commands::set_archive_home_cmd,
commands::clear_archive_home_cmd,
commands::archive_docs_cmd, commands::archive_docs_cmd,
commands::reveal_path_cmd, commands::reveal_path_cmd,
// Header im Terminal-Prozess: Projektliste, Icon und Pool-Name. // Header im Terminal-Prozess: Projektliste, Icon und Pool-Name.
+30 -5
View File
@@ -342,12 +342,37 @@ pub(crate) fn panel_archive_dir_cmd(project: String) -> Option<String> {
project_archive_home(&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 /// Setzt das Archiv-Home eines Projekts (Einstellungsdialog) — inklusive
/// das Archiv-Home zugleich; ohne `dir` muss es konfiguriert sein. /// Permissions-Eintrag in der Projekt-settings.json.
#[tauri::command] #[tauri::command]
pub(crate) fn panel_archive_cmd(project: String, dir: Option<String>) -> Result<String, String> { pub(crate) fn set_archive_home_cmd(project: String, dir: String) -> Result<(), String> {
archive_panel_content(&project, dir.as_deref(), &ArchiveMeta::default()) crate::domain::archive::set_project_archive_home(&project, &dir)
.map(|p| p.display().to_string()) }
/// Wählt das Archiv ab (Einstellungsdialog): Config-Eintrag und Permissions
/// weg, der Ordner bleibt liegen.
#[tauri::command]
pub(crate) fn clear_archive_home_cmd(project: String) -> Result<(), String> {
crate::domain::archive::clear_project_archive_home(&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(
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())
} }
/// Dokumentliste des Archiv-Index (frisch gescannt) fürs Panel. /// Dokumentliste des Archiv-Index (frisch gescannt) fürs Panel.
+51 -5
View File
@@ -20,8 +20,11 @@ pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
} }
/// Archiv-Home als Result — die gemeinsame Vorbedingung aller Archiv-Tools. /// 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> { pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
project_archive_home(project).ok_or_else(|| "kein Archiv-Ordner gesetzt".into()) 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: ai-control.json (~-relativ) und ein Eintrag in /// Setzt das Archiv-Home: ai-control.json (~-relativ) und ein Eintrag in
@@ -87,6 +90,42 @@ fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(),
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display())) fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
} }
/// Wählt das Archiv ab: archiveHome aus der ai-control.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. /// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder.
#[derive(Default)] #[derive(Default)]
pub(crate) struct ArchiveMeta { pub(crate) struct ArchiveMeta {
@@ -252,10 +291,15 @@ fn first_line(text: &str) -> String {
pub(crate) fn slugify(s: &str) -> String { pub(crate) fn slugify(s: &str) -> String {
let mut out = String::new(); let mut out = String::new();
for c in s.chars() { for c in s.chars() {
if c.is_ascii_alphanumeric() { // Umlaute/ß transliterieren statt verschlucken („für's" → fuer-s).
out.push(c.to_ascii_lowercase()); match c {
} else if !out.ends_with('-') { 'ä' | 'Ä' => out.push_str("ae"),
out.push('-'); 'ö' | 'Ö' => 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(); let out: String = out.trim_matches('-').chars().take(60).collect();
@@ -314,6 +358,8 @@ mod tests {
assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen"); assert_eq!(slugify("ADR: Logging vereinheitlichen"), "adr-logging-vereinheitlichen");
assert_eq!(slugify(" "), "entwurf"); assert_eq!(slugify(" "), "entwurf");
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] #[test]
+24
View File
@@ -134,6 +134,9 @@ pub(crate) struct WikiPage {
pub(crate) tag: Option<String>, pub(crate) tag: Option<String>,
pub(crate) total: usize, pub(crate) total: usize,
pub(crate) tags: Vec<TagCount>, 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>, pub(crate) folders: Vec<WikiFolder>,
} }
@@ -158,6 +161,8 @@ pub(crate) struct WikiDocEntry {
pub(crate) tags: Vec<String>, pub(crate) tags: Vec<String>,
/// Archivierungsdatum aus dem Zeitstempel-Stem (`YYYY-MM-DD`). /// Archivierungsdatum aus dem Zeitstempel-Stem (`YYYY-MM-DD`).
pub(crate) date: Option<String>, 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> { pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, String> {
@@ -181,6 +186,17 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
.unwrap_or_default(); .unwrap_or_default();
folders.entry(folder).or_default().push(doc); 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 { Ok(WikiPage {
kind: "page", kind: "page",
home: home.display().to_string(), home: home.display().to_string(),
@@ -190,6 +206,7 @@ pub(crate) fn archive_page(home: &Path, tag: Option<&str>) -> Result<WikiPage, S
.into_iter() .into_iter()
.map(|(name, count)| TagCount { name: name.to_string(), count }) .map(|(name, count)| TagCount { name: name.to_string(), count })
.collect(), .collect(),
recent,
folders: folders folders: folders
.into_iter() .into_iter()
.map(|(name, mut list)| { .map(|(name, mut list)| {
@@ -214,6 +231,7 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
description: doc.description.clone(), description: doc.description.clone(),
tags: doc.tags.clone(), tags: doc.tags.clone(),
date, date,
backlinks: doc.backlinks.len(),
} }
} }
@@ -342,6 +360,12 @@ mod tests {
assert_eq!(adr.date.as_deref(), Some("2026-07-19")); assert_eq!(adr.date.as_deref(), Some("2026-07-19"));
assert_eq!(page.folders[1].name, "konzepte"); assert_eq!(page.folders[1].name, "konzepte");
assert_eq!(page.folders[1].docs[0].name, "notiz-deploy"); 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] #[test]
+16 -4
View File
@@ -286,16 +286,26 @@ pub fn search_read(project: String) -> String {
std::fs::read_to_string(search_file(&project)).unwrap_or_default() std::fs::read_to_string(search_file(&project)).unwrap_or_default()
} }
/// Lädt ein Archiv-Dokument in den Dokument-Puffer (Treffer-Klick in der
/// Suche) — ohne Frontmatter-Block, wie ein frischer Entwurf. Der Watcher
/// meldet den neuen Inhalt als `panel-update`.
#[tauri::command]
pub fn panel_load(project: String, path: String) -> Result<(), String> {
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
let body = crate::domain::archive::strip_frontmatter(&text);
std::fs::write(panel_file(&project), body).map_err(|e| e.to_string())
}
/// Suche aus dem Panel-Suchfeld: läuft wie das MCP-Tool search_archive und /// 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 /// schreibt die Treffer-Datei; der Watcher zieht sie in die Ansicht (beide
/// Fenster). /// Fenster).
#[tauri::command] #[tauri::command]
pub fn search_run(project: String, query: String) -> Result<(), String> { pub fn search_run(project: String, query: String, tag: Option<String>) -> Result<(), String> {
let home = crate::domain::archive::require_archive_home(&project)?; let home = crate::domain::archive::require_archive_home(&project)?;
let hits = crate::domain::archive_search::search(&home, &query, None, 20)?; let hits = crate::domain::archive_search::search(&home, &query, tag.as_deref(), 20)?;
let payload = serde_json::json!({ let payload = serde_json::json!({
"query": query, "query": query,
"tag": null, "tag": tag,
"home": home.display().to_string(), "home": home.display().to_string(),
"hits": hits, "hits": hits,
}); });
@@ -329,8 +339,10 @@ pub fn wiki_open(project: String, name: String) -> Result<(), String> {
/// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach /// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach
/// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus. /// vorn. `panel-detached` blendet das angedockte Panel im Terminal-Fenster aus.
/// Async, weil Fenster-Erzeugung aus einem synchronen Command auf dem
/// GTK-Mainloop klemmen kann (Tauri-Vorgabe für window create in Commands).
#[tauri::command] #[tauri::command]
pub fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> { pub async fn open_panel_window(app: AppHandle, project: String) -> Result<(), String> {
let label = format!("panel-{project}"); let label = format!("panel-{project}");
if let Some(w) = app.get_webview_window(&label) { if let Some(w) = app.get_webview_window(&label) {
let _ = w.set_focus(); let _ = w.set_focus();
+54
View File
@@ -0,0 +1,54 @@
// Archiv-Formular: Aufklappen, Meta-Aufbereitung, Tastatur.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { initArchiveForm } from "./archive-form";
function setup() {
document.body.innerHTML = `<button id="btn">Archiv</button>`;
const onSubmit = vi.fn();
const form = initArchiveForm(document.getElementById("btn")!, onSubmit);
const root = document.querySelector<HTMLElement>(".archive-form")!;
const inputs = [...root.querySelectorAll("input")];
return { form, onSubmit, root, inputs };
}
describe("initArchiveForm", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("klappt auf und zu", () => {
const { form, root } = setup();
expect(root.hidden).toBe(true);
form.toggle();
expect(root.hidden).toBe(false);
form.toggle();
expect(root.hidden).toBe(true);
});
it("liefert getrimmte Meta; Leeres wird undefined, Tags gesplittet", () => {
const { form, onSubmit, root, inputs } = setup();
form.toggle();
inputs[0].value = " konzepte/panel ";
inputs[1].value = "";
inputs[2].value = " adr, infra ,, ";
root.querySelector<HTMLElement>(".archive-form-submit")!.click();
expect(onSubmit).toHaveBeenCalledWith({
folder: "konzepte/panel",
description: undefined,
tags: ["adr", "infra"],
});
expect(root.hidden).toBe(true);
});
it("Enter schickt ab, Escape schließt ohne Abschicken", () => {
const { form, onSubmit, root } = setup();
form.toggle();
root.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(root.hidden).toBe(true);
expect(onSubmit).not.toHaveBeenCalled();
form.toggle();
root.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(onSubmit).toHaveBeenCalledOnce();
expect(root.hidden).toBe(true);
});
});
+68
View File
@@ -0,0 +1,68 @@
/// Archiv-Formular: klappt unter dem Archiv-Button auf und fragt Ordner,
/// Beschreibung und Schlagwörter ab (alles optional); Enter oder der Button
/// archivieren, Escape schließt. Gemeinsam für das angedockte Panel und das
/// abgelöste Fenster.
export interface ArchiveFormMeta {
folder?: string;
description?: string;
tags: string[];
}
export function initArchiveForm(
anchor: HTMLElement,
onSubmit: (meta: ArchiveFormMeta) => void,
): { toggle(): void } {
function field(placeholder: string): HTMLInputElement {
const i = document.createElement("input");
i.type = "text";
i.placeholder = placeholder;
return i;
}
const form = document.createElement("div");
form.className = "archive-form";
form.hidden = true;
const folder = field("Ordner — z. B. konzepte/panel");
const desc = field("Beschreibung");
const tags = field("Schlagwörter, kommagetrennt");
const submit = document.createElement("button");
submit.className = "archive-form-submit";
submit.textContent = "Archivieren";
form.append(folder, desc, tags, submit);
document.body.append(form);
const meta = (): ArchiveFormMeta => ({
folder: folder.value.trim() || undefined,
description: desc.value.trim() || undefined,
tags: tags.value
.split(",")
.map((t) => t.trim())
.filter(Boolean),
});
const close = () => {
form.hidden = true;
};
const open = () => {
const r = anchor.getBoundingClientRect();
form.style.top = `${r.bottom + 6}px`;
form.style.right = `${Math.max(8, window.innerWidth - r.right - 4)}px`;
form.hidden = false;
folder.focus();
};
const fire = () => {
onSubmit(meta());
close();
};
submit.addEventListener("click", fire);
form.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
fire();
} else if (e.key === "Escape") {
close();
}
});
return { toggle: () => (form.hidden ? open() : close()) };
}
+105
View File
@@ -0,0 +1,105 @@
// Modus-Logik (Tabs) und Kachel-Ansicht der Befehls-History.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { initCommandsView, initPanelMode } from "./commands-view";
function modeSetup() {
document.body.innerHTML = `
<div id="tabs">
<button data-mode="commands">Befehle</button>
<button data-mode="draft">Dokument</button>
<button data-mode="wiki">Wiki</button>
<button data-mode="search">Suche</button>
</div>
<div id="title">Mein Titel</div>
<div id="draft-el"></div>
<div id="cc" hidden></div>
<div id="sc" hidden></div>
<div id="wc" hidden></div>`;
const el = (id: string) => document.getElementById(id)!;
const flush = vi.fn();
const mode = initPanelMode({
tabsEl: el("tabs"),
draftEls: [el("draft-el")],
commandsContent: el("cc"),
searchContent: el("sc"),
wikiContent: el("wc"),
titleEl: el("title"),
flush,
});
const tab = (m: string) =>
document.querySelector<HTMLElement>(`[data-mode="${m}"]`)!;
return { mode, flush, el, tab };
}
describe("initPanelMode", () => {
it("startet im Dokument-Modus mit aktivem Tab", () => {
const { el, tab } = modeSetup();
expect(el("draft-el").hidden).toBe(false);
expect(el("cc").hidden).toBe(true);
expect(tab("draft").classList.contains("active")).toBe(true);
});
it("wechselt Sichtbarkeit, Titel und aktive Markierung", () => {
const { mode, flush, el, tab } = modeSetup();
mode.to("commands");
expect(flush).toHaveBeenCalledOnce();
expect(el("cc").hidden).toBe(false);
expect(el("draft-el").hidden).toBe(true);
expect(el("title").textContent).toBe("Befehle");
expect(tab("commands").classList.contains("active")).toBe(true);
expect(tab("draft").classList.contains("active")).toBe(false);
mode.to("draft");
expect(el("title").textContent).toBe("Mein Titel");
});
it("Tab-Klick wechselt den Modus", () => {
const { el, tab } = modeSetup();
tab("wiki").click();
expect(el("wc").hidden).toBe(false);
expect(el("draft-el").hidden).toBe(true);
});
it("clear hebt die Auswahl auf; danach wählt to() wieder aus", () => {
const { mode, el, tab } = modeSetup();
mode.clear();
expect(document.querySelectorAll(".active").length).toBe(0);
expect(mode.current()).toBe(null);
mode.to("draft");
expect(tab("draft").classList.contains("active")).toBe(true);
expect(el("title").textContent).toBe("Mein Titel");
});
});
describe("initCommandsView", () => {
beforeEach(() => {
document.body.innerHTML = `<div id="c"></div>`;
});
const jsonl = [
JSON.stringify({ ts: 1700000000, session: true }),
JSON.stringify({
ts: 1700000100,
commands: [{ cmd: "ls -la", note: "Liste" }, { cmd: "pwd" }],
}),
].join("\n");
it("rendert Kacheln, Session-Trenner und meldet empty korrekt", () => {
const view = initCommandsView(document.getElementById("c")!, () => {});
view.set(jsonl);
expect(view.empty()).toBe(false);
expect(document.querySelectorAll(".cmd-tile").length).toBe(2);
expect(document.querySelectorAll(".cmd-sep").length).toBe(1);
expect(document.querySelector(".cmd-text")!.textContent).toBe("ls -la");
view.set("");
expect(view.empty()).toBe(true);
});
it("meldet Löschen mit Record- und Eintrags-Index", () => {
const onDelete = vi.fn();
const view = initCommandsView(document.getElementById("c")!, onDelete);
view.set(jsonl);
const dels = document.querySelectorAll<HTMLElement>(".cmd-del");
dels[1].click(); // zweite Kachel im Record 1
expect(onDelete).toHaveBeenCalledWith(1, 1);
});
});
+12 -3
View File
@@ -171,7 +171,8 @@ export function initPanelMode(opts: {
titleEl: HTMLElement; titleEl: HTMLElement;
flush: () => void; flush: () => void;
}) { }) {
let mode: PanelMode = "draft"; // `null` = kein Tab aktiv (Panel zugeklappt).
let mode: PanelMode | null = "draft";
let draftTitle = ""; let draftTitle = "";
const tabs = [...opts.tabsEl.querySelectorAll<HTMLElement>("[data-mode]")]; const tabs = [...opts.tabsEl.querySelectorAll<HTMLElement>("[data-mode]")];
@@ -181,7 +182,7 @@ export function initPanelMode(opts: {
opts.wikiContent.hidden = mode !== "wiki"; opts.wikiContent.hidden = mode !== "wiki";
for (const el of opts.draftEls) el.hidden = mode !== "draft"; for (const el of opts.draftEls) el.hidden = mode !== "draft";
for (const t of tabs) t.classList.toggle("active", t.dataset.mode === mode); for (const t of tabs) t.classList.toggle("active", t.dataset.mode === mode);
if (mode !== "draft") opts.titleEl.textContent = LABEL[mode]; if (mode && mode !== "draft") opts.titleEl.textContent = LABEL[mode];
} }
function to(m: PanelMode) { function to(m: PanelMode) {
@@ -193,7 +194,15 @@ export function initPanelMode(opts: {
if (m === "draft") opts.titleEl.textContent = draftTitle; if (m === "draft") opts.titleEl.textContent = draftTitle;
} }
/// Auswahl aufheben (Panel zugeklappt) — der nächste to()-Aufruf oder
/// Tab-Klick wählt wieder aus.
function clear() {
if (mode === "draft") draftTitle = opts.titleEl.textContent || "Dokument";
mode = null;
for (const t of tabs) t.classList.remove("active");
}
for (const t of tabs) t.addEventListener("click", () => to(t.dataset.mode as PanelMode)); for (const t of tabs) t.addEventListener("click", () => to(t.dataset.mode as PanelMode));
apply(); apply();
return { to, current: () => mode }; return { to, clear, current: () => mode };
} }
+42
View File
@@ -158,6 +158,7 @@ interface TerminalSettings {
title: string; title: string;
todo: boolean; todo: boolean;
workDirs: string[]; workDirs: string[];
archiveHome: string | null;
} }
const settings = ref<TerminalSettings | null>(null); const settings = ref<TerminalSettings | null>(null);
@@ -168,6 +169,9 @@ async function openSettings(p: Project) {
const workDirs = await invoke<string[]>("project_work_dirs", { const workDirs = await invoke<string[]>("project_work_dirs", {
project: p.name, project: p.name,
}); });
const archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
project: p.name,
});
settings.value = { settings.value = {
name: p.name, name: p.name,
path: p.path, path: p.path,
@@ -176,6 +180,7 @@ async function openSettings(p: Project) {
title: p.terminal.title ?? "", title: p.terminal.title ?? "",
todo, todo,
workDirs, workDirs,
archiveHome,
}; };
} catch (e) { } catch (e) {
error.value = String(e); error.value = String(e);
@@ -222,6 +227,33 @@ async function removeWorkDir(dir: string) {
} }
} }
// Archiv-Home schreibt wie die Arbeitsordner direkt (Config + Permissions);
// ohne Archiv zeigt das Panel nur Befehle und Dokument. Greift ab dem
// nächsten Session-Start.
async function chooseArchive() {
const s = settings.value!;
const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return;
try {
await invoke("set_archive_home_cmd", { project: s.name, dir });
s.archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
project: s.name,
});
} catch (e) {
error.value = String(e);
}
}
async function clearArchive() {
const s = settings.value!;
try {
await invoke("clear_archive_home_cmd", { project: s.name });
s.archiveHome = null;
} catch (e) {
error.value = String(e);
}
}
async function pickIcon() { async function pickIcon() {
const file = await open({ const file = await open({
multiple: false, multiple: false,
@@ -649,6 +681,16 @@ onUnmounted(() => window.clearInterval(timer));
<button @click="addWorkDir">{{ $t("projects.addWorkDir") }}</button> <button @click="addWorkDir">{{ $t("projects.addWorkDir") }}</button>
</span> </span>
</label> </label>
<label class="field">
{{ $t("projects.archive") }}
<span class="icon-row">
<span class="icon-path">{{ settings.archiveHome ?? $t("projects.archiveNone") }}</span>
<button @click="chooseArchive">{{ $t("projects.changeDir") }}</button>
<button v-if="settings.archiveHome" @click="clearArchive">
{{ $t("projects.remove") }}
</button>
</span>
</label>
<label class="field"> <label class="field">
{{ $t("projects.todo") }} {{ $t("projects.todo") }}
<span class="checkline"> <span class="checkline">
+4
View File
@@ -54,6 +54,8 @@ const de = {
addFolder: "Projekt importieren …", addFolder: "Projekt importieren …",
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen", addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
addWorkDir: "Ordner hinzufügen …", addWorkDir: "Ordner hinzufügen …",
archive: "Archiv",
archiveNone: "kein Archiv — Panel ohne Wiki und Suche",
changeDir: "Ändern …", changeDir: "Ändern …",
wizardTitle: "Neues Projekt", wizardTitle: "Neues Projekt",
name: "Name", name: "Name",
@@ -178,6 +180,8 @@ const en: typeof de = {
addFolder: "Import project …", addFolder: "Import project …",
addFolderTitle: "Add an existing folder as a project", addFolderTitle: "Add an existing folder as a project",
addWorkDir: "Add folder …", addWorkDir: "Add folder …",
archive: "Archive",
archiveNone: "no archive — panel without wiki and search",
changeDir: "Change …", changeDir: "Change …",
wizardTitle: "New project", wizardTitle: "New project",
name: "Name", name: "Name",
+120 -58
View File
@@ -12,14 +12,14 @@
width: 8px; width: 8px;
} }
#commands-content::-webkit-scrollbar-thumb { #commands-content::-webkit-scrollbar-thumb {
background: #313244; background: var(--line);
border-radius: 4px; border-radius: 4px;
} }
.cmd-sep { .cmd-sep {
margin: 16px 0 8px; margin: 16px 0 8px;
padding-top: 8px; padding-top: 8px;
border-top: 1px solid #313244; border-top: 1px solid var(--line);
color: #6c7086; color: var(--faint);
font: font:
500 11px/1 -apple-system, 500 11px/1 -apple-system,
system-ui, system-ui,
@@ -34,7 +34,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
color: #6c7086; color: var(--faint);
font: font:
500 11px/1 -apple-system, 500 11px/1 -apple-system,
system-ui, system-ui,
@@ -45,27 +45,27 @@
.cmd-all { .cmd-all {
border: none; border: none;
background: transparent; background: transparent;
color: #a6adc8; color: var(--muted);
font: inherit; font: inherit;
border-radius: 6px; border-radius: 6px;
padding: 4px 6px; padding: 4px 6px;
} }
.cmd-all:hover { .cmd-all:hover {
background: #313244; background: var(--line);
color: #cdd6f4; color: var(--text);
} }
.cmd-all.copied { .cmd-all.copied {
color: #a6e3a1; color: var(--ok);
} }
.cmd-del:hover { .cmd-del:hover {
color: #f38ba8; color: var(--err);
} }
.cmd-tile { .cmd-tile {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 6px; gap: 6px;
background: #11111b; background: var(--tile);
border: 1px solid #313244; border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
padding: 8px 6px 8px 10px; padding: 8px 6px 8px 10px;
margin-top: 6px; margin-top: 6px;
@@ -86,7 +86,7 @@
} }
.cmd-note { .cmd-note {
margin-top: 4px; margin-top: 4px;
color: #a6adc8; color: var(--muted);
font: font:
400 11px/1.4 -apple-system, 400 11px/1.4 -apple-system,
system-ui, system-ui,
@@ -104,11 +104,11 @@
.hit-search input { .hit-search input {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
background: #11111b; background: var(--tile);
border: 1px solid #313244; border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
padding: 6px 10px; padding: 6px 10px;
color: #cdd6f4; color: var(--text);
font: font:
400 12px/1.4 -apple-system, 400 12px/1.4 -apple-system,
system-ui, system-ui,
@@ -116,20 +116,20 @@
outline: none; outline: none;
} }
.hit-search input:focus { .hit-search input:focus {
border-color: #45475a; border-color: var(--line-strong);
} }
.hit-search input::placeholder { .hit-search input::placeholder {
color: #6c7086; color: var(--faint);
} }
#search-content::-webkit-scrollbar { #search-content::-webkit-scrollbar {
width: 8px; width: 8px;
} }
#search-content::-webkit-scrollbar-thumb { #search-content::-webkit-scrollbar-thumb {
background: #313244; background: var(--line);
border-radius: 4px; border-radius: 4px;
} }
.hit-head { .hit-head {
color: #6c7086; color: var(--faint);
font: font:
500 11px/1 -apple-system, 500 11px/1 -apple-system,
system-ui, system-ui,
@@ -138,15 +138,15 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
.hit-tile { .hit-tile {
background: #11111b; background: var(--tile);
border: 1px solid #313244; border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
padding: 8px 10px; padding: 8px 10px;
margin-top: 6px; margin-top: 6px;
cursor: pointer; cursor: pointer;
} }
.hit-tile:hover { .hit-tile:hover {
border-color: #45475a; border-color: var(--line-strong);
} }
.hit-title { .hit-title {
font: font:
@@ -156,7 +156,7 @@
} }
.hit-snippet { .hit-snippet {
margin-top: 4px; margin-top: 4px;
color: #a6adc8; color: var(--muted);
font: font:
400 11px/1.5 -apple-system, 400 11px/1.5 -apple-system,
system-ui, system-ui,
@@ -164,12 +164,12 @@
} }
.hit-snippet mark { .hit-snippet mark {
background: transparent; background: transparent;
color: #f9e2af; color: var(--warn);
font-weight: 600; font-weight: 600;
} }
.hit-path { .hit-path {
margin-top: 4px; margin-top: 4px;
color: #6c7086; color: var(--faint);
font-family: "JetBrains Mono", Menlo, monospace; font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px; font-size: 10px;
} }
@@ -185,7 +185,7 @@
width: 8px; width: 8px;
} }
#wiki-content::-webkit-scrollbar-thumb { #wiki-content::-webkit-scrollbar-thumb {
background: #313244; background: var(--line);
border-radius: 4px; border-radius: 4px;
} }
.wiki-head { .wiki-head {
@@ -198,7 +198,7 @@
min-width: 0; min-width: 0;
} }
.wiki-head-title { .wiki-head-title {
color: #cdd6f4; color: var(--text);
font: font:
600 13px/1.3 -apple-system, 600 13px/1.3 -apple-system,
system-ui, system-ui,
@@ -206,7 +206,7 @@
} }
.wiki-head-sub { .wiki-head-sub {
margin-top: 2px; margin-top: 2px;
color: #6c7086; color: var(--faint);
font-family: "JetBrains Mono", Menlo, monospace; font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px; font-size: 10px;
overflow: hidden; overflow: hidden;
@@ -214,7 +214,7 @@
white-space: nowrap; white-space: nowrap;
} }
.wiki-head-right { .wiki-head-right {
color: #6c7086; color: var(--faint);
font: font:
500 11px/1 -apple-system, 500 11px/1 -apple-system,
system-ui, system-ui,
@@ -228,9 +228,9 @@
margin: 10px 0 4px; margin: 10px 0 4px;
} }
.wiki-chip { .wiki-chip {
border: 1px solid #313244; border: 1px solid var(--line);
background: transparent; background: transparent;
color: #a6adc8; color: var(--muted);
border-radius: 999px; border-radius: 999px;
padding: 2px 8px; padding: 2px 8px;
font: font:
@@ -239,19 +239,19 @@
sans-serif; sans-serif;
} }
.wiki-chip:hover { .wiki-chip:hover {
background: #313244; background: var(--line);
color: #cdd6f4; color: var(--text);
} }
.wiki-chip.active { .wiki-chip.active {
background: #313244; background: var(--line);
border-color: #45475a; border-color: var(--line-strong);
color: #89b4fa; color: var(--accent);
} }
.wiki-folder { .wiki-folder {
margin: 14px 0 2px; margin: 14px 0 2px;
padding-top: 10px; padding-top: 10px;
border-top: 1px solid #313244; border-top: 1px solid var(--line);
color: #6c7086; color: var(--faint);
font: font:
500 10px/1 -apple-system, 500 10px/1 -apple-system,
system-ui, system-ui,
@@ -268,7 +268,7 @@
cursor: pointer; cursor: pointer;
} }
.wiki-doc:hover { .wiki-doc:hover {
background: #11111b; background: var(--tile);
} }
.wiki-doc-line { .wiki-doc-line {
display: flex; display: flex;
@@ -278,20 +278,20 @@
.wiki-doc-title { .wiki-doc-title {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
color: #89b4fa; color: var(--accent);
font: font:
600 12px/1.4 -apple-system, 600 12px/1.4 -apple-system,
system-ui, system-ui,
sans-serif; sans-serif;
} }
.wiki-doc-date { .wiki-doc-date {
color: #6c7086; color: var(--faint);
font-family: "JetBrains Mono", Menlo, monospace; font-family: "JetBrains Mono", Menlo, monospace;
font-size: 10px; font-size: 10px;
} }
.wiki-doc-desc { .wiki-doc-desc {
margin-top: 2px; margin-top: 2px;
color: #a6adc8; color: var(--muted);
font: font:
400 11px/1.5 -apple-system, 400 11px/1.5 -apple-system,
system-ui, system-ui,
@@ -305,7 +305,7 @@
} }
.wiki-empty { .wiki-empty {
margin-top: 18px; margin-top: 18px;
color: #a6adc8; color: var(--muted);
font: font:
400 12px/1.6 -apple-system, 400 12px/1.6 -apple-system,
system-ui, system-ui,
@@ -314,7 +314,7 @@
.wiki-empty strong { .wiki-empty strong {
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
color: #cdd6f4; color: var(--text);
font-weight: 600; font-weight: 600;
} }
.wiki-doc-head { .wiki-doc-head {
@@ -327,7 +327,7 @@
.wiki-back { .wiki-back {
border: none; border: none;
background: transparent; background: transparent;
color: #a6adc8; color: var(--muted);
font: font:
500 11px/1 -apple-system, 500 11px/1 -apple-system,
system-ui, system-ui,
@@ -336,8 +336,8 @@
padding: 4px 6px; padding: 4px 6px;
} }
.wiki-back:hover { .wiki-back:hover {
background: #313244; background: var(--line);
color: #cdd6f4; color: var(--text);
} }
.wiki-body { .wiki-body {
font-size: 12.5px; font-size: 12.5px;
@@ -374,12 +374,12 @@
.wiki-body code { .wiki-body code {
font-family: "JetBrains Mono", Menlo, monospace; font-family: "JetBrains Mono", Menlo, monospace;
font-size: 0.9em; font-size: 0.9em;
background: #313244; background: var(--line);
border-radius: 4px; border-radius: 4px;
padding: 1px 5px; padding: 1px 5px;
} }
.wiki-body pre { .wiki-body pre {
background: #11111b; background: var(--tile);
border-radius: 8px; border-radius: 8px;
padding: 12px 14px; padding: 12px 14px;
overflow: auto; overflow: auto;
@@ -389,28 +389,90 @@
padding: 0; padding: 0;
} }
.wiki-body blockquote { .wiki-body blockquote {
border-left: 3px solid #313244; border-left: 3px solid var(--line);
padding-left: 12px; padding-left: 12px;
color: #a6adc8; color: var(--muted);
} }
.wiki-body a { .wiki-body a {
color: #89b4fa; color: var(--accent);
} }
.wiki-backlinks { .wiki-backlinks {
margin-top: 14px; margin-top: 14px;
padding-top: 8px; padding-top: 8px;
border-top: 1px solid #313244; border-top: 1px solid var(--line);
color: #6c7086; color: var(--faint);
font: font:
500 11px/1.6 -apple-system, 500 11px/1.6 -apple-system,
system-ui, system-ui,
sans-serif; sans-serif;
} }
.wiki-backlinks a { .wiki-backlinks a {
color: #89b4fa; color: var(--accent);
text-decoration: none; text-decoration: none;
} }
/* Backlink-Zähler in der Dokumentzeile. */
.wiki-doc-back {
color: var(--faint);
font:
500 10px/1 -apple-system,
system-ui,
sans-serif;
white-space: nowrap;
}
/* Archiv-Formular: klappt unter dem Archiv-Button auf. */
.archive-form {
position: fixed;
z-index: 20;
display: flex;
flex-direction: column;
gap: 6px;
width: 260px;
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.archive-form[hidden] {
display: none;
}
.archive-form input {
box-sizing: border-box;
width: 100%;
background: var(--tile);
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
color: var(--text);
font:
400 12px/1.4 -apple-system,
system-ui,
sans-serif;
outline: none;
}
.archive-form input:focus {
border-color: var(--line-strong);
}
.archive-form input::placeholder {
color: var(--faint);
}
.archive-form-submit {
border: none;
background: var(--line);
color: var(--text);
border-radius: 6px;
padding: 6px 10px;
font:
500 12px/1.4 -apple-system,
system-ui,
sans-serif;
}
.archive-form-submit:hover {
background: var(--line-strong);
}
/* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */ /* Sichtbare Fehlermeldung (panelToast), oben rechts über dem Panel-Inhalt. */
.panel-toast { .panel-toast {
position: fixed; position: fixed;
@@ -418,9 +480,9 @@
right: 12px; right: 12px;
z-index: 10; z-index: 10;
max-width: 320px; max-width: 320px;
background: #11111b; background: var(--tile);
border: 1px solid #f38ba8; border: 1px solid var(--err);
color: #f38ba8; color: var(--err);
border-radius: 8px; border-radius: 8px;
padding: 8px 12px; padding: 8px 12px;
font: font:
+68
View File
@@ -0,0 +1,68 @@
// Dokument-Ansicht: Wikilink-Verlinkung im DOM und Editor-Flush.
import { describe, expect, it, vi } from "vitest";
import { initPanelView, linkWikiRefs } from "./panel-view";
describe("linkWikiRefs", () => {
it("verlinkt [[ziel]] und [[ziel|Label]], Code bleibt unangetastet", () => {
const root = document.createElement("div");
root.innerHTML =
"<p>Siehe [[adr-logging]] und [[notiz|die Notiz]].</p>" +
"<pre><code>if [[ -f x ]]; then</code></pre>";
const onClick = vi.fn();
linkWikiRefs(root, onClick);
const links = [...root.querySelectorAll<HTMLElement>("a.wiki")];
expect(links.map((a) => a.textContent)).toEqual(["adr-logging", "die Notiz"]);
links[1].click();
expect(onClick).toHaveBeenCalledWith("notiz");
expect(root.querySelector("code")!.textContent).toBe("if [[ -f x ]]; then");
});
});
function viewSetup(onCommit: (text: string) => void | Promise<void>) {
document.body.innerHTML = `
<div id="content"></div>
<button id="copy"></button>
<button id="mode"></button>
<button id="edit"></button>`;
const view = initPanelView({
content: document.getElementById("content")!,
copyBtn: document.getElementById("copy")!,
modeBtn: document.getElementById("mode")!,
editContentBtn: document.getElementById("edit")!,
onCommit,
});
const editor = document.querySelector<HTMLTextAreaElement>(".panel-editor")!;
return { view, editor, editBtn: document.getElementById("edit")! };
}
describe("initPanelView — Editor", () => {
it("flush speichert eine offene Bearbeitung und beendet sie", async () => {
const onCommit = vi.fn();
const { view, editor, editBtn } = viewSetup(onCommit);
view.set("Alt");
editBtn.click();
expect(editor.hidden).toBe(false);
editor.value = "Neu";
await view.flush();
expect(onCommit).toHaveBeenCalledWith("Neu");
expect(editor.hidden).toBe(true);
});
it("flush ohne offene Bearbeitung tut nichts", async () => {
const onCommit = vi.fn();
const { view } = viewSetup(onCommit);
view.set("Alt");
await view.flush();
expect(onCommit).not.toHaveBeenCalled();
});
it("puffert eingehende Updates während der Bearbeitung", () => {
const { view, editor, editBtn } = viewSetup(() => {});
view.set("Alt");
editBtn.click();
view.set("Update von außen");
expect(editBtn.classList.contains("changed")).toBe(true);
editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(view.raw()).toBe("Update von außen");
});
});
+42 -9
View File
@@ -21,7 +21,7 @@ import "./panel-tiles.css";
export interface PanelWiring { export interface PanelWiring {
view: PanelView; view: PanelView;
cmdView: CommandsView; cmdView: CommandsView;
mode: { to(m: PanelMode): void; current(): PanelMode }; mode: { to(m: PanelMode): void; clear(): void; current(): PanelMode | null };
/// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung). /// Entwurfstext beim Start (für die Anfangs-Modus-Entscheidung).
draft: string; draft: string;
} }
@@ -31,17 +31,37 @@ export async function wirePanel(
onIncoming?: () => void, onIncoming?: () => void,
): Promise<PanelWiring> { ): Promise<PanelWiring> {
const titleEl = document.querySelector(".panel-title") as HTMLElement; const titleEl = document.querySelector(".panel-title") as HTMLElement;
const [defaultLang, draft, cmds, search, wiki] = await Promise.all([ const [defaultLang, draft, cmds, search, wiki, archiveHome] = await Promise.all([
invoke<string>("spellcheck_lang"), invoke<string>("spellcheck_lang"),
invoke<string>("panel_read", { project }), invoke<string>("panel_read", { project }),
invoke<string>("commands_read", { project }), invoke<string>("commands_read", { project }),
invoke<string>("search_read", { project }), invoke<string>("search_read", { project }),
invoke<string>("wiki_read", { project }), invoke<string>("wiki_read", { project }),
invoke<string | null>("panel_archive_dir_cmd", { project }),
]); ]);
// Jeder Wiki-Sprung (Wikilink, Chip, Suchtreffer) geht als ein Invoke an den // Ohne Archiv (in den Projekt-Einstellungen abgewählt) gibt es nur Befehle
// Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück. // und Dokument — Wiki-/Suche-Tabs und die Archiv-Werkzeuge verschwinden.
const openWiki = (name: string) => invoke("wiki_open", { project, name }); if (!archiveHome) {
for (const sel of [
'#panel-tabs [data-mode="wiki"]',
'#panel-tabs [data-mode="search"]',
"#panel-archive",
"#panel-wiki-jump",
]) {
const el = document.querySelector<HTMLElement>(sel)!;
el.hidden = true;
// Raus aus der draft-only-Menge, sonst blendet der Modus-Umschalter
// die Archiv-Werkzeuge im Dokument-Modus wieder ein.
el.classList.remove("draft-only");
}
}
// Jeder Wiki-Sprung (Wikilink, Chip, Dokument-Sprung) geht als ein Invoke an
// den Kern; das Ergebnis kommt über den Wiki-Puffer und `wiki-update` zurück.
// Fehler (z. B. Ziel nicht im Archiv) erscheinen als Toast.
const openWiki = (name: string) =>
invoke("wiki_open", { project, name }).catch((e) => panelToast(String(e)));
const view = initPanelView({ const view = initPanelView({
content: document.getElementById("panel-content")!, content: document.getElementById("panel-content")!,
@@ -58,13 +78,21 @@ export async function wirePanel(
const cmdView = initCommandsView(document.getElementById("commands-content")!, (line, entry) => const cmdView = initCommandsView(document.getElementById("commands-content")!, (line, entry) =>
invoke("commands_delete", { project, line, entry }), invoke("commands_delete", { project, line, entry }),
); );
// Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und
// archivierbar); der Sprung ins Wiki geht von dort aus.
const searchView = initSearchView( const searchView = initSearchView(
document.getElementById("search-content")!, document.getElementById("search-content")!,
(relpath) => openWiki(relpath.split("/").pop()!.replace(/\.md$/, "")), (path) =>
(query) => void invoke("panel_load", { project, path }).catch((e) => panelToast(String(e))),
void invoke("search_run", { project, query }).catch((e) => (raw) => {
// `#tag`-Tokens filtern aufs Schlagwort, der Rest ist die Volltext-Query.
const words = raw.split(/\s+/).filter(Boolean);
const tag = words.find((w) => w.startsWith("#"))?.slice(1) ?? null;
const query = words.filter((w) => !w.startsWith("#")).join(" ");
void invoke("search_run", { project, query, tag }).catch((e) =>
panelToast(`Suche fehlgeschlagen: ${e}`), panelToast(`Suche fehlgeschlagen: ${e}`),
), );
},
); );
const wikiView = initWikiView(document.getElementById("wiki-content")!, openWiki); const wikiView = initWikiView(document.getElementById("wiki-content")!, openWiki);
const mode = initPanelMode({ const mode = initPanelMode({
@@ -92,6 +120,11 @@ export async function wirePanel(
if (wikiView.empty()) void openWiki("tag:"); if (wikiView.empty()) void openWiki("tag:");
}); });
// Sprung Dokument → Wiki: löst den angezeigten Titel gegen das Archiv auf.
document
.getElementById("panel-wiki-jump")!
.addEventListener("click", () => void openWiki(titleEl.textContent || ""));
await listen<string>("panel-update", (e) => { await listen<string>("panel-update", (e) => {
view.set(e.payload); view.set(e.payload);
mode.to("draft"); mode.to("draft");
+65 -12
View File
@@ -2,10 +2,12 @@ import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css"; import "@fontsource/jetbrains-mono/500.css";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import { wirePanel } from "./panel-wiring"; import { wirePanel } from "./panel-wiring";
import { flash, panelToast } from "./commands-view"; import { flash, panelToast } from "./commands-view";
import { THEMES } from "./themes"; import { initArchiveForm } from "./archive-form";
import { applyTheme, THEMES } from "./themes";
// Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt // Abgelöstes Panel-Fenster: liest den aktuellen Entwurf einmal ein und folgt
// danach denselben Update-Events wie das angedockte Panel; startet in // danach denselben Update-Events wie das angedockte Panel; startet in
@@ -13,6 +15,23 @@ import { THEMES } from "./themes";
const project = new URLSearchParams(location.search).get("project")!; const project = new URLSearchParams(location.search).get("project")!;
const win = getCurrentWebviewWindow(); const win = getCurrentWebviewWindow();
// Debug-Instrumentierung wie im Terminal-Fenster: Fehler auf der Seite
// anzeigen und ins Dev-Log spiegeln — ein toter Start ist sonst unsichtbar.
function showError(msg: string) {
const el = document.createElement("pre");
el.style.cssText =
"color:#f38ba8;padding:16px;font:12px Menlo,monospace;white-space:pre-wrap";
el.textContent = msg;
document.body.appendChild(el);
invoke("term_log", { msg: `panel: ${msg}` });
}
window.addEventListener("error", (e) =>
showError(`error: ${e.message}\n${e.error?.stack ?? ""}`),
);
window.addEventListener("unhandledrejection", (e) =>
showError(`rejection: ${e.reason}\n${e.reason?.stack ?? ""}`),
);
// Dekorationsloses Fenster (Linux): Plattform-Flag + Resize-Zonen wie im // Dekorationsloses Fenster (Linux): Plattform-Flag + Resize-Zonen wie im
// Terminal-Fenster; macOS behält die native Deko. // Terminal-Fenster; macOS behält die native Deko.
const isMac = /Mac|Macintosh/.test(navigator.userAgent); const isMac = /Mac|Macintosh/.test(navigator.userAgent);
@@ -32,6 +51,37 @@ if (!isMac) {
} }
} }
// Doppelklick auf die Kopfleiste maximiert (Fenster-Konvention).
document.querySelector(".panel-topbar")!.addEventListener("dblclick", (e) => {
if ((e.target as HTMLElement).hasAttribute("data-tauri-drag-region")) {
win.toggleMaximize();
}
});
// Fenstergeometrie merken und beim nächsten Öffnen wiederherstellen; unter
// Wayland vergibt der Compositor die Position, dann greift nur die Größe.
const GEO_KEY = `panel-geometry:${project}`;
const savedGeo = localStorage.getItem(GEO_KEY);
if (savedGeo) {
const g = JSON.parse(savedGeo);
await win.setSize(new PhysicalSize(g.w, g.h));
await win.setPosition(new PhysicalPosition(g.x, g.y));
}
let geoTimer: number | undefined;
const saveGeo = () => {
clearTimeout(geoTimer);
geoTimer = window.setTimeout(async () => {
const pos = await win.outerPosition();
const size = await win.innerSize();
localStorage.setItem(
GEO_KEY,
JSON.stringify({ x: pos.x, y: pos.y, w: size.width, h: size.height }),
);
}, 300);
};
await win.onMoved(saveGeo);
await win.onResized(saveGeo);
// Farben ans Theme koppeln — derselbe Look wie das angedockte Panel im // Farben ans Theme koppeln — derselbe Look wie das angedockte Panel im
// Terminal-Fenster (die CSS-Defaults sind Mocha). // Terminal-Fenster (die CSS-Defaults sind Mocha).
interface Project { interface Project {
@@ -40,23 +90,19 @@ interface Project {
} }
const projects = await invoke<Project[]>("list_projects"); const projects = await invoke<Project[]>("list_projects");
const picked = THEMES[projects.find((p) => p.name === project)?.terminal.theme ?? "mocha"]; const picked = THEMES[projects.find((p) => p.name === project)?.terminal.theme ?? "mocha"];
applyTheme(picked);
// Fensterhintergrund des Panel-Fensters ist die Kopf-Fläche, nicht das
// Terminal-Dunkel.
document.documentElement.style.background = picked.header; document.documentElement.style.background = picked.header;
document.body.style.background = picked.header; document.body.style.background = picked.header;
document.body.style.color = picked.xterm.foreground;
document.body.style.borderColor = picked.border;
const topbar = document.querySelector(".panel-topbar") as HTMLElement;
topbar.style.background = picked.header;
topbar.style.borderBottomColor = picked.border;
const head = document.querySelector(".panel-head") as HTMLElement;
head.style.background = picked.header;
head.style.borderBottomColor = picked.border;
const { view, cmdView, mode, draft } = await wirePanel(project); const { view, cmdView, mode, draft } = await wirePanel(project);
if (!draft.trim() && !cmdView.empty()) mode.to("commands"); if (!draft.trim() && !cmdView.empty()) mode.to("commands");
// Archivieren: wie im angedockten Panel — Ordner nehmen oder per Dialog wählen. // Archivieren: wie im angedockten Panel — Formular aufklappen, Abschicken
// wählt notfalls erst das Archiv-Home per Dialog.
const archiveBtn = document.getElementById("panel-archive")!; const archiveBtn = document.getElementById("panel-archive")!;
archiveBtn.addEventListener("click", async () => { const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
try { try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", { const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project, project,
@@ -69,7 +115,13 @@ archiveBtn.addEventListener("click", async () => {
dir = chosen as string; dir = chosen as string;
} }
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
const path = await invoke<string>("panel_archive_cmd", { project, dir }); const path = await invoke<string>("panel_archive_cmd", {
project,
dir,
folder: meta.folder ?? null,
description: meta.description ?? null,
tags: meta.tags,
});
flash(archiveBtn, "copied", 1400); flash(archiveBtn, "copied", 1400);
invoke("reveal_path_cmd", { path }); invoke("reveal_path_cmd", { path });
} catch (e) { } catch (e) {
@@ -77,6 +129,7 @@ archiveBtn.addEventListener("click", async () => {
panelToast(`Archivieren fehlgeschlagen: ${e}`); panelToast(`Archivieren fehlgeschlagen: ${e}`);
} }
}); });
archiveBtn.addEventListener("click", () => archiveForm.toggle());
// „Andocken": angedocktes Panel wieder einblenden, dann dieses Fenster schließen. // „Andocken": angedocktes Panel wieder einblenden, dann dieses Fenster schließen.
document.getElementById("panel-dock")!.addEventListener("click", async () => { document.getElementById("panel-dock")!.addEventListener("click", async () => {
+101
View File
@@ -0,0 +1,101 @@
// Suchfeld-Logik (Live-Suche, Debounce, Schwelle) und Treffer-Kacheln.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initSearchView } from "./search-view";
function setup() {
document.body.innerHTML = `<div id="s"></div>`;
const onOpen = vi.fn();
const onSearch = vi.fn();
const view = initSearchView(document.getElementById("s")!, onOpen, onSearch);
const input = document.querySelector<HTMLInputElement>(".hit-search input")!;
const type = (v: string) => {
input.value = v;
input.dispatchEvent(new Event("input"));
};
const enter = () =>
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
return { view, onOpen, onSearch, input, type, enter };
}
const run = JSON.stringify({
query: "arch",
tag: null,
home: "/tmp/archiv",
hits: [{ relpath: "a/2026-01-01_0000-x.md", title: "X", snippet: "ein **arch**iv" }],
});
describe("initSearchView", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("sucht ab 3 Zeichen nach 300 ms mit Präfix-Stern", () => {
const { onSearch, type } = setup();
type("arch");
expect(onSearch).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledWith("arch*");
});
it("hängt an #tag-Tokens keinen Stern an", () => {
const { onSearch, type } = setup();
type("panel #adr");
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledWith("panel #adr");
});
it("entprellt: nur die letzte Eingabe zählt", () => {
const { onSearch, type } = setup();
type("arc");
vi.advanceTimersByTime(200);
type("arch");
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledOnce();
expect(onSearch).toHaveBeenCalledWith("arch*");
});
it("räumt beim Löschen unter 3 Zeichen auf und zeigt den Hinweis", () => {
const { view, onSearch, type } = setup();
view.set(run);
expect(document.querySelectorAll(".hit-tile").length).toBe(1);
type("ar");
expect(onSearch).not.toHaveBeenCalled();
expect(document.querySelectorAll(".hit-tile").length).toBe(0);
expect(document.querySelector(".hit-head")!.textContent).toContain(
"Mindestens 3 Zeichen",
);
type("");
expect(document.querySelector(".hit-head")).toBe(null);
});
it("Enter sucht sofort und wörtlich", () => {
const { onSearch, type, enter } = setup();
type("panel arch");
enter();
expect(onSearch).toHaveBeenCalledWith("panel arch");
});
it("rendert Treffer; Klick liefert den absoluten Pfad", () => {
const { view, onOpen } = setup();
view.set(run);
const tile = document.querySelector<HTMLElement>(".hit-tile")!;
expect(tile.querySelector(".hit-title")!.textContent).toBe("X");
expect(tile.querySelectorAll("mark").length).toBe(1);
tile.click();
expect(onOpen).toHaveBeenCalledWith("/tmp/archiv/a/2026-01-01_0000-x.md");
});
it("zeigt „Keine Treffer“ bei leerem Ergebnis", () => {
const { view } = setup();
view.set(JSON.stringify({ query: "nix", tag: "adr", home: "/a", hits: [] }));
expect(document.querySelector(".hit-head")!.textContent).toBe(
"Keine Treffer für „nix“ · #adr",
);
expect(view.empty()).toBe(true);
});
it("übernimmt die Query eines fremden Suchlaufs ins Feld", () => {
const { view, input } = setup();
view.set(run);
expect(input.value).toBe("arch");
});
});
+26 -9
View File
@@ -1,6 +1,6 @@
/// Kachel-Ansicht der Archiv-Suchtreffer: rendert die Suchtreffer-Datei /// Kachel-Ansicht der Archiv-Suchtreffer: rendert die Suchtreffer-Datei
/// (search_archive im MCP-Server) als klickbare Kacheln — Klick reicht den /// (search_archive im MCP-Server) als klickbare Kacheln — Klick reicht den
/// relpath des Treffers an onOpen. Titel/Snippet/Pfad sind Fremdtext und /// absoluten Pfad des Treffers an onOpen. Titel/Snippet/Pfad sind Fremdtext und
/// gehen nie durch innerHTML; die `**…**`-Marker im Snippet werden per /// gehen nie durch innerHTML; die `**…**`-Marker im Snippet werden per
/// Split in <mark>-Elemente übersetzt. /// Split in <mark>-Elemente übersetzt.
@@ -24,7 +24,7 @@ export interface SearchView {
export function initSearchView( export function initSearchView(
container: HTMLElement, container: HTMLElement,
onOpen: (relpath: string) => void, onOpen: (path: string) => void,
onSearch: (query: string) => void, onSearch: (query: string) => void,
): SearchView { ): SearchView {
let count = 0; let count = 0;
@@ -34,16 +34,30 @@ export function initSearchView(
bar.className = "hit-search"; bar.className = "hit-search";
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "search"; input.type = "search";
input.placeholder = "Archiv durchsuchen"; input.placeholder = "Archiv durchsuchen — #tag filtert";
// Live-Suche ab 3 Zeichen, entprellt (300 ms Debounce); endet die Eingabe // Live-Suche ab 3 Zeichen, entprellt (300 ms Debounce); endet die Eingabe
// mitten im Wort, wird das letzte Wort als Präfix gesucht (arch → arch*). // mitten im Wort, wird das letzte Wort als Präfix gesucht (arch → arch*)
// Enter sucht sofort und wörtlich — für exakte FTS-Syntax (Phrasen, OR/NOT). // außer es ist ein #tag-Token. Enter sucht sofort und wörtlich — für exakte
// FTS-Syntax (Phrasen, OR/NOT).
let pending: number | undefined; let pending: number | undefined;
input.addEventListener("input", () => { input.addEventListener("input", () => {
clearTimeout(pending); clearTimeout(pending);
const q = input.value.trim(); const q = input.value.trim();
if (q.length < 3) return; if (q.length < 3) {
const live = /[\p{L}\p{N}]$/u.test(q) ? `${q}*` : q; // Beim Löschen unter die Schwelle: alte Treffer weg, kurzer Hinweis.
count = 0;
results.textContent = "";
if (q) {
const head = document.createElement("div");
head.className = "hit-head";
head.textContent = "Mindestens 3 Zeichen — oder Enter.";
results.append(head);
}
return;
}
const last = q.split(/\s+/).pop()!;
const live =
!last.startsWith("#") && /[\p{L}\p{N}]$/u.test(q) ? `${q}*` : q;
pending = window.setTimeout(() => onSearch(live), 300); pending = window.setTimeout(() => onSearch(live), 300);
}); });
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
@@ -77,7 +91,10 @@ export function initSearchView(
head.className = "hit-head"; head.className = "hit-head";
const what = run.query.trim() ? `${run.query}` : ""; const what = run.query.trim() ? `${run.query}` : "";
const tag = run.tag ? `#${run.tag}` : ""; const tag = run.tag ? `#${run.tag}` : "";
head.textContent = `${run.hits.length} Treffer ${[what, tag].filter(Boolean).join(" · ")}`; const scope = [what, tag].filter(Boolean).join(" · ");
head.textContent = run.hits.length
? `${run.hits.length} Treffer ${scope}`
: `Keine Treffer für ${scope}`;
results.append(head); results.append(head);
for (const hit of run.hits) { for (const hit of run.hits) {
const tile = document.createElement("div"); const tile = document.createElement("div");
@@ -89,7 +106,7 @@ export function initSearchView(
path.className = "hit-path"; path.className = "hit-path";
path.textContent = hit.relpath; path.textContent = hit.relpath;
tile.append(title, snippetEl(hit.snippet), path); tile.append(title, snippetEl(hit.snippet), path);
tile.addEventListener("click", () => onOpen(hit.relpath)); tile.addEventListener("click", () => onOpen(`${run.home}/${hit.relpath}`));
results.append(tile); results.append(tile);
} }
} }
+37 -22
View File
@@ -11,8 +11,9 @@ import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { wirePanel } from "./panel-wiring"; import { wirePanel } from "./panel-wiring";
import { THEMES } from "./themes"; import { applyTheme, THEMES } from "./themes";
import { flash, panelToast } from "./commands-view"; import { flash, panelToast } from "./commands-view";
import { initArchiveForm } from "./archive-form";
// Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln. // Debug-Instrumentierung: Fehler auf der Seite anzeigen und ins Dev-Log spiegeln.
function showError(msg: string) { function showError(msg: string) {
@@ -56,6 +57,13 @@ if (!isMac) {
} }
} }
// Doppelklick auf die Kopfleiste maximiert (Fenster-Konvention).
document.querySelector("header")!.addEventListener("dblclick", (e) => {
if ((e.target as HTMLElement).hasAttribute("data-tauri-drag-region")) {
win.toggleMaximize();
}
});
// Fensterhintergrund beim Öffnen kommt aus terminal.rs (theme_background); // Fensterhintergrund beim Öffnen kommt aus terminal.rs (theme_background);
// dort nicht gepflegte Themes fallen auf Mocha zurück (nur kurzer Moment // dort nicht gepflegte Themes fallen auf Mocha zurück (nur kurzer Moment
// bis die Webview lädt). // bis die Webview lädt).
@@ -90,14 +98,8 @@ if (cfg?.terminal.icon) {
const picked = THEMES[cfg?.terminal.theme ?? "mocha"]; const picked = THEMES[cfg?.terminal.theme ?? "mocha"];
const theme = picked.xterm; const theme = picked.xterm;
// Seitenfarben ans Theme anpassen (Defaults in terminal.html sind Mocha). // Seitenfarben ans Theme anpassen — alles Weitere hängt an den CSS-Variablen.
document.documentElement.style.background = theme.background; applyTheme(picked);
document.body.style.background = theme.background;
document.body.style.borderColor = picked.border;
const header = document.querySelector("header") as HTMLElement;
header.style.background = picked.header;
header.style.borderBottomColor = picked.border;
header.style.color = theme.foreground;
// Globale Schriftgröße (settings.json: terminalFontSize), ein Wert für alle. // Globale Schriftgröße (settings.json: terminalFontSize), ein Wert für alle.
const DEFAULT_FONT_SIZE = 13; const DEFAULT_FONT_SIZE = 13;
@@ -186,13 +188,6 @@ term.focus();
const panel = document.getElementById("panel")!; const panel = document.getElementById("panel")!;
const splitter = document.getElementById("splitter")!; const splitter = document.getElementById("splitter")!;
// Panelfarben ans Theme koppeln.
panel.style.background = picked.header;
panel.style.color = theme.foreground;
splitter.style.background = picked.border;
(panel.querySelector(".panel-head") as HTMLElement).style.borderBottomColor =
picked.border;
let detached = false; let detached = false;
let hasContent = false; let hasContent = false;
@@ -209,39 +204,52 @@ function hidePanel() {
// Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende // Gemeinsame Verdrahtung (Views, Modi, Update-Events); jedes eingehende
// Update blendet das angedockte Panel ein, solange es nicht abgelöst ist. // Update blendet das angedockte Panel ein, solange es nicht abgelöst ist.
const { view } = await wirePanel(project, () => { const { view, mode } = await wirePanel(project, () => {
hasContent = true; hasContent = true;
if (!detached) showPanel(); if (!detached) showPanel();
}); });
// Panel startet zugeklappt — kein Tab aktiv, bis eine Ansicht geöffnet wird.
mode.clear();
// Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist. // Tab-Klick im Header öffnet das (angedockte) Panel, falls es zu ist.
document.getElementById("panel-tabs")!.addEventListener("click", () => { const headerTabs = document.getElementById("panel-tabs")!;
headerTabs.addEventListener("click", () => {
hasContent = true; hasContent = true;
if (!detached) showPanel(); if (!detached) showPanel();
}); });
// Solange das Panel abgelöst ist, hat das eigene Fenster die Tabs — die im
// Header verschwinden.
await listen("panel-detached", () => { await listen("panel-detached", () => {
detached = true; detached = true;
headerTabs.hidden = true;
hidePanel(); hidePanel();
mode.clear();
}); });
await listen("panel-attached", () => { await listen("panel-attached", () => {
detached = false; detached = false;
headerTabs.hidden = false;
if (hasContent) showPanel(); if (hasContent) showPanel();
}); });
// Abgelöstes Fenster geschlossen (per ✕ oder OS): angedocktes Panel bleibt // Abgelöstes Fenster geschlossen (per ✕ oder OS): angedocktes Panel bleibt
// ausgeblendet, aber der nächste Entwurf darf es wieder einblenden. // ausgeblendet, aber der nächste Entwurf darf es wieder einblenden.
await listen("panel-window-closed", () => { await listen("panel-window-closed", () => {
detached = false; detached = false;
headerTabs.hidden = false;
}); });
document document
.getElementById("panel-detach")! .getElementById("panel-detach")!
.addEventListener("click", () => invoke("open_panel_window", { project })); .addEventListener("click", () => invoke("open_panel_window", { project }));
document.getElementById("panel-hide")!.addEventListener("click", hidePanel); document.getElementById("panel-hide")!.addEventListener("click", () => {
hidePanel();
mode.clear();
});
// Archivieren: konfigurierten Ordner nehmen, sonst per Dialog wählen (setzt ihn). // Archivieren: Button klappt das Formular (Ordner/Beschreibung/Schlagwörter)
// auf; Abschicken wählt notfalls erst das Archiv-Home per Dialog.
const archiveBtn = document.getElementById("panel-archive")!; const archiveBtn = document.getElementById("panel-archive")!;
archiveBtn.addEventListener("click", async () => { const archiveForm = initArchiveForm(archiveBtn, async (meta) => {
try { try {
const configured = await invoke<string | null>("panel_archive_dir_cmd", { const configured = await invoke<string | null>("panel_archive_dir_cmd", {
project, project,
@@ -254,7 +262,13 @@ archiveBtn.addEventListener("click", async () => {
dir = chosen as string; dir = chosen as string;
} }
await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist await view.flush(); // offene Bearbeitung erst speichern — archiviert, was zu sehen ist
const path = await invoke<string>("panel_archive_cmd", { project, dir }); const path = await invoke<string>("panel_archive_cmd", {
project,
dir,
folder: meta.folder ?? null,
description: meta.description ?? null,
tags: meta.tags,
});
flash(archiveBtn, "copied", 1400); flash(archiveBtn, "copied", 1400);
invoke("reveal_path_cmd", { path }); invoke("reveal_path_cmd", { path });
} catch (e) { } catch (e) {
@@ -263,6 +277,7 @@ archiveBtn.addEventListener("click", async () => {
invoke("term_log", { msg: `archive: ${e}` }); invoke("term_log", { msg: `archive: ${e}` });
} }
}); });
archiveBtn.addEventListener("click", () => archiveForm.toggle());
// Splitter: Panelbreite ziehen. // Splitter: Panelbreite ziehen.
splitter.addEventListener("mousedown", (e) => { splitter.addEventListener("mousedown", (e) => {
+25
View File
@@ -1,6 +1,31 @@
/// Farbschemata der Fenster: Header-/Rahmenfarbe plus xterm-Theme — /// Farbschemata der Fenster: Header-/Rahmenfarbe plus xterm-Theme —
/// genutzt vom Terminal-Fenster (Header, angedocktes Panel) und vom /// genutzt vom Terminal-Fenster (Header, angedocktes Panel) und vom
/// abgelösten Panel-Fenster (panel.ts), damit beide gleich aussehen. /// abgelösten Panel-Fenster (panel.ts), damit beide gleich aussehen.
/// Überträgt ein Theme auf die CSS-Variablen der Seite; die Defaults in den
/// HTML-Styleblöcken sind Mocha. Fehlt einem Theme ein Farbwert, bleibt der
/// Mocha-Default stehen.
export function applyTheme(picked: (typeof THEMES)[string]) {
const x = picked.xterm;
const vars: Record<string, string | undefined> = {
"--bg": x.background,
"--surface": picked.header,
"--tile": x.background,
"--line": picked.border,
"--line-strong": x.black,
"--text": x.foreground,
"--muted": x.brightWhite,
"--faint": x.brightBlack,
"--accent": x.blue,
"--ok": x.green,
"--warn": x.yellow,
"--err": x.red,
};
for (const [k, v] of Object.entries(vars)) {
if (v) document.documentElement.style.setProperty(k, v);
}
}
export const THEMES: Record< export const THEMES: Record<
string, string,
{ header: string; border: string; xterm: Record<string, string> } { header: string; border: string; xterm: Record<string, string> }
+147
View File
@@ -0,0 +1,147 @@
// Wiki-Ansicht: Übersichts-/Schlagwort-Seiten, Dokumentseite, Leerzustände.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { initWikiView } from "./wiki-view";
function setup() {
document.body.innerHTML = `<div id="w"></div>`;
const onLink = vi.fn();
const view = initWikiView(document.getElementById("w")!, onLink);
return { view, onLink };
}
const doc = (name: string, extra: object = {}) => ({
name,
title: name.toUpperCase(),
description: null,
tags: [],
date: "2026-07-19",
backlinks: 0,
...extra,
});
const page = JSON.stringify({
kind: "page",
home: "/tmp/archiv",
tag: null,
total: 3,
tags: [
{ name: "adr", count: 2 },
{ name: "infra", count: 1 },
],
recent: [doc("neu")],
folders: [
{ name: "", docs: [doc("wurzel-doc", { backlinks: 2, tags: ["adr"] })] },
{ name: "konzepte", docs: [doc("neu"), doc("alt")] },
],
});
describe("initWikiView — Seiten", () => {
it("rendert Kopf, Chips, Zuletzt- und Ordner-Sektionen", () => {
const { view } = setup();
view.set(page);
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("Archiv");
expect(document.querySelector(".wiki-head-right")!.textContent).toBe("3 Dokumente");
const chips = [...document.querySelectorAll(".wiki-chips .wiki-chip")].map(
(c) => c.textContent,
);
expect(chips).toEqual(["Alle", "#adr 2", "#infra 1"]);
const eyebrows = [...document.querySelectorAll(".wiki-folder")].map(
(e) => e.textContent,
);
expect(eyebrows).toEqual(["Zuletzt", "Wurzel", "konzepte/"]);
expect(document.querySelector(".wiki-doc-back")!.textContent).toBe("↩ 2");
});
it("Dokumentzeile öffnet das Dokument, Tag-Chip die Tag-Seite", () => {
const { view, onLink } = setup();
view.set(page);
const rows = document.querySelectorAll<HTMLElement>(".wiki-doc");
rows[1].click(); // Wurzel-Dokument (nach der Zuletzt-Zeile)
expect(onLink).toHaveBeenCalledWith("wurzel-doc");
rows[1].querySelector<HTMLElement>(".wiki-doc-tags .wiki-chip")!.click();
expect(onLink).toHaveBeenCalledWith("tag:adr");
document.querySelector<HTMLElement>(".wiki-chips .wiki-chip")!.click();
expect(onLink).toHaveBeenCalledWith("tag:");
});
it("Tag-Seite: Titel, aktiver Chip, Leerfall", () => {
const { view } = setup();
view.set(
JSON.stringify({
kind: "page",
home: "/a",
tag: "adr",
total: 0,
tags: [{ name: "adr", count: 1 }],
recent: [],
folders: [],
}),
);
expect(document.querySelector(".wiki-head-title")!.textContent).toBe("#adr");
const active = document.querySelector(".wiki-chip.active")!;
expect(active.textContent).toBe("#adr 1");
expect(document.querySelector(".wiki-empty strong")!.textContent).toBe(
"Keine Dokumente mit #adr.",
);
});
it("leeres Archiv erklärt das Archivieren", () => {
const { view } = setup();
view.set(
JSON.stringify({
kind: "page",
home: "/a",
tag: null,
total: 0,
tags: [],
recent: [],
folders: [],
}),
);
expect(document.querySelector(".wiki-empty")!.textContent).toContain(
"Das Archiv ist leer.",
);
});
});
describe("initWikiView — Dokument und Leerzustand", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("rendert Markdown, Rücksprung, Tags und Backlinks", () => {
const { view, onLink } = setup();
view.set(
JSON.stringify({
kind: "doc",
home: "/a",
relpath: "konzepte/2026-07-19_1000-x.md",
name: "x",
title: "X",
tags: ["adr"],
backlinks: ["anderes-doc"],
markdown: "# Überschrift\n\nText mit [[ziel|Label]].\n",
}),
);
expect(document.querySelector(".wiki-body h1")!.textContent).toBe("Überschrift");
const wiki = document.querySelector<HTMLElement>(".wiki-body a.wiki")!;
expect(wiki.textContent).toBe("Label");
wiki.click();
expect(onLink).toHaveBeenCalledWith("ziel");
document.querySelector<HTMLElement>(".wiki-back")!.click();
expect(onLink).toHaveBeenCalledWith("tag:");
const back = document.querySelector<HTMLElement>(".wiki-backlinks a")!;
back.click();
expect(onLink).toHaveBeenCalledWith("anderes-doc");
});
it("leerer Puffer bietet den Übersichts-Einstieg", () => {
const { view, onLink } = setup();
view.set("");
expect(view.empty()).toBe(true);
document.querySelector<HTMLElement>(".wiki-empty .wiki-chip")!.click();
expect(onLink).toHaveBeenCalledWith("tag:");
view.set(page);
expect(view.empty()).toBe(false);
});
});
+21
View File
@@ -14,6 +14,7 @@ interface DocEntry {
description?: string | null; description?: string | null;
tags: string[]; tags: string[];
date?: string | null; date?: string | null;
backlinks: number;
} }
interface Folder { interface Folder {
@@ -27,6 +28,7 @@ interface Page {
tag?: string | null; tag?: string | null;
total: number; total: number;
tags: { name: string; count: number }[]; tags: { name: string; count: number }[];
recent: DocEntry[];
folders: Folder[]; folders: Folder[];
} }
@@ -103,6 +105,13 @@ export function initWikiView(
title.className = "wiki-doc-title"; title.className = "wiki-doc-title";
title.textContent = doc.title; title.textContent = doc.title;
line.append(title); line.append(title);
if (doc.backlinks) {
const back = document.createElement("div");
back.className = "wiki-doc-back";
back.title = "Verweise hierher";
back.textContent = `${doc.backlinks}`;
line.append(back);
}
if (doc.date) { if (doc.date) {
const date = document.createElement("div"); const date = document.createElement("div");
date.className = "wiki-doc-date"; date.className = "wiki-doc-date";
@@ -143,12 +152,24 @@ export function initWikiView(
container.append(empty); container.append(empty);
return; return;
} }
if (p.recent.length) {
const eyebrow = document.createElement("div");
eyebrow.className = "wiki-folder";
eyebrow.textContent = "Zuletzt";
container.append(eyebrow);
for (const doc of p.recent) container.append(docRow(doc));
}
for (const folder of p.folders) { for (const folder of p.folders) {
if (folder.name) { if (folder.name) {
const eyebrow = document.createElement("div"); const eyebrow = document.createElement("div");
eyebrow.className = "wiki-folder"; eyebrow.className = "wiki-folder";
eyebrow.textContent = `${folder.name}/`; eyebrow.textContent = `${folder.name}/`;
container.append(eyebrow); container.append(eyebrow);
} else if (p.recent.length) {
const eyebrow = document.createElement("div");
eyebrow.className = "wiki-folder";
eyebrow.textContent = "Wurzel";
container.append(eyebrow);
} }
for (const doc of folder.docs) container.append(docRow(doc)); for (const doc of folder.docs) container.append(docRow(doc));
} }
+66 -34
View File
@@ -4,11 +4,26 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Terminal</title> <title>Terminal</title>
<style> <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, html,
body { body {
height: 100%; height: 100%;
margin: 0; margin: 0;
background: #1e1e2e; background: var(--bg);
overflow: hidden; overflow: hidden;
} }
body { body {
@@ -18,7 +33,7 @@
/* Feiner Fensterrahmen fürs dekorationslose Fenster (nur Linux). */ /* Feiner Fensterrahmen fürs dekorationslose Fenster (nur Linux). */
:root[data-platform="other"] body { :root[data-platform="other"] body {
box-sizing: border-box; box-sizing: border-box;
border: 1px solid #313244; border: 1px solid var(--line);
} }
header { header {
height: 40px; height: 40px;
@@ -28,9 +43,9 @@
gap: 10px; gap: 10px;
/* Linux: schmaler Rand links; macOS: Ampel-Platz (Override unten) */ /* Linux: schmaler Rand links; macOS: Ampel-Platz (Override unten) */
padding: 0 0 0 16px; padding: 0 0 0 16px;
background: #181825; background: var(--surface);
border-bottom: 1px solid #313244; border-bottom: 1px solid var(--line);
color: #cdd6f4; color: var(--text);
font: font:
500 13px/1 -apple-system, 500 13px/1 -apple-system,
system-ui, system-ui,
@@ -59,16 +74,23 @@
header .panel-tabs { header .panel-tabs {
margin-left: auto; margin-left: auto;
} }
header .panel-tabs[hidden] {
display: none;
}
/* Ohne Tabs (Panel abgelöst) rückt das Pool-Badge selbst nach rechts. */
header .panel-tabs[hidden] + #pool {
margin-left: auto;
}
.tab-sep { .tab-sep {
width: 1px; width: 1px;
height: 16px; height: 16px;
background: #45475a; background: var(--line-strong);
margin: 0 6px; margin: 0 6px;
align-self: center; align-self: center;
} }
#pool { #pool {
background: #313244; background: var(--line);
color: #a6adc8; color: var(--muted);
border-radius: 999px; border-radius: 999px;
padding: 4px 12px; padding: 4px 12px;
font-size: 11px; font-size: 11px;
@@ -103,14 +125,14 @@
opacity: 0.8; opacity: 0.8;
} }
.winbtn:hover { .winbtn:hover {
background: #313244; background: var(--line);
} }
.winbtn:hover svg { .winbtn:hover svg {
opacity: 1; opacity: 1;
} }
.winbtn.close:hover { .winbtn.close:hover {
background: #f38ba8; background: var(--err);
color: #11111b; color: var(--tile);
} }
/* Resize-Zonen für das dekorationslose Fenster (nur Linux). */ /* Resize-Zonen für das dekorationslose Fenster (nur Linux). */
#resize-grips { #resize-grips {
@@ -199,7 +221,7 @@
width: 8px; width: 8px;
} }
#term .xterm-viewport::-webkit-scrollbar-thumb { #term .xterm-viewport::-webkit-scrollbar-thumb {
background: #313244; background: var(--line);
border-radius: 4px; border-radius: 4px;
} }
#term .xterm-viewport::-webkit-scrollbar-track { #term .xterm-viewport::-webkit-scrollbar-track {
@@ -210,7 +232,7 @@
flex: none; flex: none;
width: 5px; width: 5px;
cursor: col-resize; cursor: col-resize;
background: #313244; background: var(--line);
} }
#panel { #panel {
flex: none; flex: none;
@@ -218,8 +240,8 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #181825; background: var(--surface);
color: #cdd6f4; color: var(--text);
} }
#panel[hidden], #panel[hidden],
#splitter[hidden] { #splitter[hidden] {
@@ -231,7 +253,7 @@
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
padding: 6px 8px 8px 12px; padding: 6px 8px 8px 12px;
border-bottom: 1px solid #313244; border-bottom: 1px solid var(--line);
font: font:
500 12px/1 -apple-system, 500 12px/1 -apple-system,
system-ui, system-ui,
@@ -261,7 +283,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: #a6adc8; color: var(--muted);
} }
.panel-title[contenteditable="true"] { .panel-title[contenteditable="true"] {
overflow: visible; overflow: visible;
@@ -273,7 +295,7 @@
.panel-btn { .panel-btn {
border: none; border: none;
background: transparent; background: transparent;
color: #a6adc8; color: var(--muted);
cursor: default; cursor: default;
border-radius: 6px; border-radius: 6px;
padding: 5px 8px; padding: 5px 8px;
@@ -282,14 +304,14 @@
align-items: center; align-items: center;
} }
.panel-btn:hover { .panel-btn:hover {
background: #313244; background: var(--line);
color: #cdd6f4; color: var(--text);
} }
.panel-btn.copied { .panel-btn.copied {
color: #a6e3a1; color: var(--ok);
} }
.panel-btn.error { .panel-btn.error {
color: #f38ba8; color: var(--err);
} }
.panel-btn svg { .panel-btn svg {
display: block; display: block;
@@ -300,11 +322,18 @@
stroke-linejoin: round; stroke-linejoin: round;
} }
.panel-btn.active { .panel-btn.active {
background: #313244; background: var(--line);
color: #cdd6f4; 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 { .panel-btn.changed {
color: #f9e2af; color: var(--warn);
} }
.panel-editor { .panel-editor {
flex: 1; flex: 1;
@@ -331,7 +360,7 @@
-webkit-appearance: none; -webkit-appearance: none;
margin-right: auto; margin-right: auto;
background: transparent; background: transparent;
color: #a6adc8; color: var(--muted);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
padding: 6px 9px; padding: 6px 9px;
@@ -339,8 +368,8 @@
cursor: default; cursor: default;
} }
.panel-lang:hover { .panel-lang:hover {
background: #313244; background: var(--line);
color: #cdd6f4; color: var(--text);
} }
#panel-content { #panel-content {
flex: 1; flex: 1;
@@ -357,7 +386,7 @@
width: 8px; width: 8px;
} }
#panel-content::-webkit-scrollbar-thumb { #panel-content::-webkit-scrollbar-thumb {
background: #313244; background: var(--line);
border-radius: 4px; border-radius: 4px;
} }
#panel-content.raw { #panel-content.raw {
@@ -394,12 +423,12 @@
#panel-content.md code { #panel-content.md code {
font-family: "JetBrains Mono", Menlo, monospace; font-family: "JetBrains Mono", Menlo, monospace;
font-size: 0.9em; font-size: 0.9em;
background: #313244; background: var(--line);
border-radius: 4px; border-radius: 4px;
padding: 1px 5px; padding: 1px 5px;
} }
#panel-content.md pre { #panel-content.md pre {
background: #11111b; background: var(--tile);
border-radius: 8px; border-radius: 8px;
padding: 12px 14px; padding: 12px 14px;
overflow: auto; overflow: auto;
@@ -409,12 +438,12 @@
padding: 0; padding: 0;
} }
#panel-content.md blockquote { #panel-content.md blockquote {
border-left: 3px solid #313244; border-left: 3px solid var(--line);
padding-left: 12px; padding-left: 12px;
color: #a6adc8; color: var(--muted);
} }
#panel-content.md a { #panel-content.md a {
color: #89b4fa; color: var(--accent);
} }
.panel-btn[hidden], .panel-btn[hidden],
.panel-lang[hidden], .panel-lang[hidden],
@@ -473,6 +502,9 @@
<option value="it">IT</option> <option value="it">IT</option>
<option value="nl">NL</option> <option value="nl">NL</option>
</select> </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-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)"> <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> <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>
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "happy-dom",
include: ["src/**/*.test.ts"],
},
});