Icons zentral unter ~/.config/ai-control/icons (pool-/repo-unabhängig), nicht ladbares Icon crasht den Tray nicht mehr; Arbeitsordner nachträglich im ⚙︎-Dialog erfassbar (additionalDirectories + Edit-Permission); Import-Button umbenannt; claudeCommand konfigurierbar + CLAUDE_CONFIG_DIR aus Pool; Pool-Tabellen-Layout; README englisch mit Screenshots; MIT-Lizenz

This commit is contained in:
marcus.hinz
2026-07-05 15:19:58 +02:00
parent b5995da755
commit 0908385a9d
14 changed files with 329 additions and 83 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Marcus Hinz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+68 -39
View File
@@ -1,73 +1,102 @@
# ai-control
Pool- und Session-Verwaltung für [Claude Code](https://claude.com/claude-code) auf macOS — als Tray-App mit eingebautem Terminal. Linux- und Windows-Integration folgen.
Pool and session management for [Claude Code](https://claude.com/claude-code) on macOS — a tray app with a built-in terminal. Linux and Windows integration to follow.
> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.
> All code in this project was generated by Claude (Claude Code).
## Zweck
<img src="docs/projects.png" width="640" alt="Projects">
Wer Claude Code mit mehreren Accounts bzw. Credential-Sets betreibt, muss pro Session das richtige `CLAUDE_CONFIG_DIR` samt Keychain-Eintrag treffen. ai-control macht daraus Klicks:
## Purpose
- **Pools** — benannte Credential-Sets (OAuth-Login oder API-Key) unter `~/.config/ai-control/pools/<pool>`. Jeder Pool ist ein eigenständiges Claude-Config-Verzeichnis mit eigenem Keychain-Eintrag.
- **Projekte** — beliebige Verzeichnisse, gemappt über eine Registry (`~/.config/ai-control/projects.json`, Name → Pfad; Home-Pfade als `~/…` und damit maschinenübergreifend stabil). Jedem Projekt ist ein Pool zugeordnet; dazu Terminal-Einstellungen (Theme, Icon, Fenstertitel).
- **Sessions** — pro Projekt ein eingebautes Terminal (xterm.js + PTY), das Claude Code mit der Pool-Umgebung startet. Jedes Terminal läuft als eigener Prozess mit eigenem Dock-Icon und Cmd-Tab-Eintrag.
- **Tray** — die App selbst ist eine reine Menüleisten-App ohne Dock-Eintrag. Das Tray-Menü listet alle Projekte mit Icon und Status-Punkt (grün = läuft); Klick startet das Projekt oder holt das laufende Terminal nach vorn.
- **Session-Watcher** — erkennt das Ende einer Session über das Verschwinden des Terminal-Prozesses und synct dann (opt-in) das Git-Repo, in dem das Projekt liegt: add → commit → pull --rebase → push.
Running Claude Code with multiple accounts or credential sets means hitting the right `CLAUDE_CONFIG_DIR` (plus keychain entry) for every session. ai-control turns that into clicks:
## Projektaufbau
- **Pools** — named credential sets (OAuth login or API key) under `~/.config/ai-control/pools/<pool>`. Each pool is a self-contained Claude config directory with its own keychain entry.
- **Projects** — arbitrary directories, mapped through a registry (`~/.config/ai-control/projects.json`, name → path; paths under home are stored as `~/…` and therefore stable across machines). Each project is assigned a pool, plus terminal settings (theme, icon, window title).
- **Sessions** — a built-in terminal per project (xterm.js + PTY) that launches Claude Code with the pool's config directory as `CLAUDE_CONFIG_DIR`. Every terminal runs as its own process with its own Dock icon and Cmd-Tab entry.
- **Tray** — the app itself is a pure menu-bar app without a Dock entry. The tray menu lists all projects with icon and status dot (green = running); clicking starts the project or brings the running terminal to the front.
- **Session watcher** — detects the end of a session by the terminal process disappearing and then (opt-in) syncs the Git repository containing the project: add → commit → pull --rebase → push.
## Screenshots
| Pools | Usage |
|---|---|
| ![Pools](docs/pools.png) | ![Usage](docs/usage.png) |
| Tray menu | Session terminal |
|---|---|
| ![Tray menu](docs/tray.png) | ![Session terminal](docs/terminal.png) |
## Project layout
```
├── index.html Haupt-UI (Projekt-/Pool-Verwaltung)
├── terminal.html Terminal-Fenster (xterm.js)
├── src/ Frontend: Vue 3 + TypeScript
├── index.html main UI (project/pool management)
├── terminal.html terminal window (xterm.js)
├── src/ frontend: Vue 3 + TypeScript
├── src-tauri/
│ ├── src/lib.rs Tauri-Commands: Projekte, Pools, Keychain,
│ │ OAuth-Login, Tray-Menü, Session-Watcher
│ ├── src/terminal.rs PTY-Sessions (portable-pty), Terminal-Fenster,
│ │ Dock-Icon, Fokussieren laufender Terminals
│ └── icons/ App- und Tray-Icons
├── dev.sh Entwicklungsmodus (tauri dev)
└── build.sh Release-Build (.app-Bundle)
│ ├── src/lib.rs Tauri commands: projects, pools, keychain,
│ │ OAuth login, tray menu, session watcher
│ ├── src/terminal.rs PTY sessions (portable-pty), terminal windows,
│ │ Dock icon, focusing running terminals
│ └── icons/ app and tray icons
├── dev.sh development mode (tauri dev)
└── build.sh release build (.app bundle)
```
Zwei Prozessrollen aus einem Binary:
Two process roles from one binary:
- **Haupt-App** (`ai-control`) — Tray, Hauptfenster, Verwaltung, Watcher.
- **Terminal-Prozess** (`ai-control --terminal <projekt>`) — ein Fenster mit eigener PTY; startet Claude Code im Projektverzeichnis mit `CLAUDE_CONFIG_DIR` des zugeordneten Pools.
- **Main app** (`ai-control`) — tray, main window, management, watcher.
- **Terminal process** (`ai-control --terminal <project>`) — one window with its own PTY; launches Claude Code in the project directory with the assigned pool's `CLAUDE_CONFIG_DIR`.
Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.
Running projects are detected through their terminal processes (`pgrep` for `--terminal <project>`), without a state file.
## Tooling
| Bereich | Stack |
| Area | Stack |
|-----------|-------|
| Shell | Tauri 2 (Rust) |
| Frontend | Vue 3, TypeScript, Vite |
| Terminal | xterm.js, portable-pty |
| macOS | objc2 / objc2-app-kit (Dock-Icon, Fenster-Fokus, Tray) |
| macOS | objc2 / objc2-app-kit (Dock icon, window focus, tray) |
| Secrets | keyring (macOS Keychain) |
## Entwicklung
## Development
```sh
./dev.sh # tauri dev mit Hot-Reload
./build.sh # Release-Build; Bundle landet in src-tauri/target/release/bundle/macos.noindex/
./dev.sh # tauri dev with hot reload
./build.sh # release build; bundle lands in src-tauri/target/release/bundle/macos.noindex/
```
`build.sh` verschiebt das Bundle in ein `.noindex`-Verzeichnis und trägt es aus Launch Services aus, damit Spotlight nur die installierte Kopie findet. Installation: `ai-control.app` nach `~/Applications` kopieren.
`build.sh` moves the bundle into a `.noindex` directory and unregisters it from Launch Services so Spotlight only finds the installed copy. Install by copying `ai-control.app` to `~/Applications`.
Voraussetzungen: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` erwarten `CARGO_HOME`/`RUSTUP_HOME` unter `~/tools/`bei Standard-Installation die beiden Exporte in den Skripten anpassen.
Prerequisites: Rust (stable), Node.js/npm, macOS. `dev.sh`/`build.sh` expect `CARGO_HOME`/`RUSTUP_HOME` under `~/tools/`adjust the two exports in the scripts for a standard installation.
## Konfigurationslayout
## Configuration layout
```
~/.config/ai-control/
├── settings.json App-Einstellungen (z. B. syncOnSessionEnd)
├── projects.json Projekt-Registry: Name → Ordner
└── pools/<pool>/ je Pool ein Claude-Config-Verzeichnis
└── pool.json Name + Credential-Typ
├── settings.json app settings (see below)
├── projects.json project registry: name → directory
└── pools/<pool>/ one Claude config directory per pool
└── pool.json name + credential type
<projektordner>/ beliebiger Pfad, per Registry gemappt
├── .claude/ Claude-Code-Projektkonfiguration
└── ai-control.json Pool-Zuordnung + Terminal-Einstellungen
<project directory>/ any path, mapped via the registry
├── .claude/ Claude Code project configuration
└── ai-control.json pool assignment + terminal settings
```
### settings.json
| Key | Default | Meaning |
|--------------------|----------|---------|
| `claudeCommand` | `claude` | Command launched in the project terminal (via `zsh -ic`, so your `.zshrc` PATH applies). |
| `syncOnSessionEnd` | `false` | After a session ends, commit and push the Git repository containing the project. |
## Claude and Anthropic licensing
ai-control is pool management only — it does not change, extend, or circumvent Anthropic's licensing in any way. Using Claude Code through ai-control is subject to the same Anthropic terms as running it directly.
For that reason the app stores no OAuth tokens itself: with an OAuth pool, the first session of a project on that pool goes through the regular Anthropic login (`/login`, browser, against your subscription), run by Claude Code itself. Credentials live where Claude Code puts them — in the macOS keychain, one entry per pool.
## License
[MIT](LICENSE)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+1
View File
@@ -2,6 +2,7 @@
"name": "ai-control",
"private": true,
"version": "0.0.0",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -3,7 +3,7 @@ name = "ai-control"
version = "0.1.0"
description = "Pool- und Session-Verwaltung für Claude Code"
authors = ["marcus.hinz"]
license = ""
license = "MIT"
repository = ""
edition = "2021"
rust-version = "1.77.2"
+177 -34
View File
@@ -48,6 +48,12 @@ impl Paths {
self.config_dir().join("pools")
}
/// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
/// unabhängig von Pool-Zuordnung und Quell-Repos.
fn icons_dir(&self) -> PathBuf {
self.config_dir().join("icons")
}
fn pool_dir(&self, pool: &str) -> PathBuf {
self.pools_dir().join(pool)
}
@@ -208,6 +214,22 @@ fn sync_on_session_end(paths: &Paths) -> bool {
.unwrap_or(false)
}
/// Kommando, das im Projekt-Terminal startet (settings.json: claudeCommand).
pub(crate) fn claude_command(paths: &Paths) -> String {
fs::read_to_string(paths.config_dir().join(APP_SETTINGS_FILE))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v["claudeCommand"].as_str().map(str::to_string))
.unwrap_or_else(|| "claude".into())
}
/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
/// CLAUDE_CONFIG_DIR mitgegeben.
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
let paths = Paths::real();
Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
}
/// Setzt das Opt-in; erhält übrige App-settings.
fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String> {
let path = paths.config_dir().join(APP_SETTINGS_FILE);
@@ -563,6 +585,76 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
register_project(paths, name, &dir)
}
/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im
/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die
/// Datei an, wenn sie fehlt (importierte Projekte).
fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
check_name(name)?;
let wd_path = expand_home(paths, dir);
if !wd_path.is_dir() {
return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
}
let dir = contract_home(paths, &wd_path);
let sp = settings_path(&project_dir(paths, name)?);
let mut v: serde_json::Value = if sp.is_file() {
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
} else {
serde_json::json!({})
};
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
let perms = root
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or("permissions ist kein Objekt")?;
let dirs = perms
.entry("additionalDirectories")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("additionalDirectories ist kein Array")?;
if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
return Err(format!("schon eingetragen: {dir}"));
}
dirs.push(serde_json::json!(dir));
let allow = perms
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("allow ist kein Array")?;
allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
/// zugehörige Edit-Permission. Der Ordner selbst bleibt.
fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
check_name(name)?;
let sp = settings_path(&project_dir(paths, name)?);
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
let mut v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
let perms = v["permissions"]
.as_object_mut()
.ok_or("permissions ist kein Objekt")?;
let dirs = perms["additionalDirectories"]
.as_array_mut()
.ok_or("additionalDirectories ist kein Array")?;
let before = dirs.len();
dirs.retain(|d| d.as_str() != Some(dir));
if dirs.len() == before {
return Err(format!("nicht eingetragen: {dir}"));
}
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
check_name(name)?;
@@ -648,8 +740,9 @@ fn set_terminal_config_in(
project: &str,
mut terminal: TerminalConfig,
) -> Result<(), String> {
// Absolut gewählte Icons in den Projektordner kopieren und relativ
// speichern — so wird das Icon mit dem Projekt-Repo gesynct.
let mut cfg = read_project_config_in(paths, project)?;
// Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als
// Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo.
if let Some(icon) = terminal.icon.as_deref() {
if icon.starts_with('/') {
let src = PathBuf::from(icon);
@@ -658,25 +751,27 @@ fn set_terminal_config_in(
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let name = format!("icon.{ext}");
let dest = project_dir(paths, project)?.join(&name);
let name = format!("{project}.{ext}");
let icons = paths.icons_dir();
fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?;
let dest = icons.join(&name);
if src != dest {
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
}
terminal.icon = Some(name);
}
}
let mut cfg = read_project_config_in(paths, project)?;
cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)
}
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner.
fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> Result<PathBuf, String> {
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
/// gemeinsamen Icons-Verzeichnis der App.
fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
if icon.starts_with('/') {
Ok(PathBuf::from(icon))
PathBuf::from(icon)
} else {
Ok(project_dir(paths, project)?.join(icon))
paths.icons_dir().join(icon)
}
}
@@ -689,7 +784,7 @@ fn project_icon(project: String) -> Result<Option<String>, String> {
let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
return Ok(None);
};
let path = resolve_icon_path(&paths, &project, &icon)?;
let path = resolve_icon_path(&paths, &icon);
let is_icns = path
.extension()
.and_then(|e| e.to_str())
@@ -742,34 +837,49 @@ fn menu_icon(
}
}
if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
let src = resolve_icon_path(paths, project, &icon)?;
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
let out = Command::new("sips")
.args(["-s", "format", "png", "-z", "36", "36"])
.arg(&src)
.arg("--out")
.arg(&tmp)
.output()
.map_err(|e| format!("sips: {e}"))?;
if !out.status.success() {
return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr)));
}
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
let _ = fs::remove_file(&tmp);
let img = tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())?;
let (iw, ih) = (img.width() as usize, img.height() as usize);
let rgba = img.rgba();
for y in 0..ih.min(H) {
for x in 0..iw.min(H) {
let src_i = (y * iw + x) * 4;
let dst_i = (y * W + DOT_W + x) * 4;
canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]);
// Nicht ladbares Icon blockiert den Tray nicht (Muster set_dock_icon):
// Meldung, Eintrag erscheint ohne Projekt-Icon.
match project_icon_rgba_36(paths, project, &icon) {
Ok(img) => {
let (iw, ih) = (img.width() as usize, img.height() as usize);
let rgba = img.rgba();
for y in 0..ih.min(H) {
for x in 0..iw.min(H) {
let src_i = (y * iw + x) * 4;
let dst_i = (y * W + DOT_W + x) * 4;
canvas[dst_i..dst_i + 4].copy_from_slice(&rgba[src_i..src_i + 4]);
}
}
}
Err(e) => eprintln!("Tray-Icon {project}: {e}"),
}
}
Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32))
}
/// Projekt-Icon als 36×36-RGBA (sips konvertiert, auch ICNS).
fn project_icon_rgba_36(
paths: &Paths,
project: &str,
icon: &str,
) -> Result<tauri::image::Image<'static>, String> {
let src = resolve_icon_path(paths, icon);
let tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
let out = Command::new("sips")
.args(["-s", "format", "png", "-z", "36", "36"])
.arg(&src)
.arg("--out")
.arg(&tmp)
.output()
.map_err(|e| format!("sips: {e}"))?;
if !out.status.success() {
return Err(format!("sips: {}", String::from_utf8_lossy(&out.stderr)));
}
let bytes = fs::read(&tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
let _ = fs::remove_file(&tmp);
tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())
}
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
Ok(read_project_config_in(&Paths::real(), project)?.terminal)
@@ -1434,6 +1544,16 @@ fn remove_project(name: String) -> Result<(), String> {
unregister_project(&Paths::real(), &name)
}
#[tauri::command]
fn add_work_dir(project: String, dir: String) -> Result<(), String> {
add_work_dir_in(&Paths::real(), &project, &dir)
}
#[tauri::command]
fn remove_work_dir(project: String, dir: String) -> Result<(), String> {
remove_work_dir_in(&Paths::real(), &project, &dir)
}
#[tauri::command]
fn todo_state(project: String) -> Result<bool, String> {
todo_state_in(&Paths::real(), &project)
@@ -1645,8 +1765,7 @@ pub fn run() {
.expect("Projekt-Config nicht lesbar")
.icon
.map(|i| {
resolve_icon_path(&Paths::real(), &project, &i)
.expect("Projekt nicht registriert")
resolve_icon_path(&Paths::real(), &i)
.to_string_lossy()
.into_owned()
});
@@ -1787,6 +1906,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
remove_project,
delete_project,
project_work_dirs,
add_work_dir,
remove_work_dir,
list_pools,
create_oauth_pool,
create_apikey_pool,
@@ -2610,6 +2731,28 @@ mod tests {
assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty());
}
#[test]
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
add_work_dir_in(&p, "proj", &picked).unwrap();
// gespeichert wird ~-kontrahiert, in beiden permissions-Feldern
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/extra"]);
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
assert!(raw.contains("Edit(~/projects/extra/**)"));
assert!(add_work_dir_in(&p, "proj", &picked).is_err()); // doppelt
remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap();
assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty());
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
assert!(!raw.contains("~/projects/extra"));
// Ordner selbst bleibt
assert!(p.home.join("projects/extra").is_dir());
}
#[test]
fn projekt_loeschen_unbekannt_scheitert() {
let p = tmp_paths();
+9 -5
View File
@@ -140,9 +140,9 @@ pub fn build_window(
Ok(())
}
/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
/// claude-sync selbst.
/// Startet die PTY für das rufende Fenster: das konfigurierte Claude-Kommando
/// (settings.json: claudeCommand, Default claude) im Projektordner, mit dem
/// Pool-Verzeichnis als CLAUDE_CONFIG_DIR. zsh -i lädt die .zshrc (PATH, fnm).
#[tauri::command]
pub fn term_start(
window: tauri::WebviewWindow,
@@ -151,16 +151,20 @@ pub fn term_start(
rows: u16,
cols: u16,
) -> Result<(), String> {
let cwd = crate::project_dir(&crate::Paths::real(), &project)?;
let paths = crate::Paths::real();
let cwd = crate::project_dir(&paths, &project)?;
let pty = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())?;
let mut cmd = CommandBuilder::new("/bin/zsh");
cmd.args(["-ic", "claude-sync"]);
cmd.args(["-ic", &crate::claude_command(&paths)]);
cmd.cwd(&cwd);
cmd.env("TERM", "xterm-256color");
if let Some(pool_dir) = crate::project_pool_dir(&project)? {
cmd.env("CLAUDE_CONFIG_DIR", pool_dir);
}
let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
drop(pty.slave);
+39
View File
@@ -156,6 +156,7 @@ interface TerminalSettings {
icon: string | null;
title: string;
todo: boolean;
workDirs: string[];
}
const settings = ref<TerminalSettings | null>(null);
@@ -163,18 +164,46 @@ const settings = ref<TerminalSettings | null>(null);
async function openSettings(p: Project) {
try {
const todo = await invoke<boolean>("todo_state", { project: p.name });
const workDirs = await invoke<string[]>("project_work_dirs", {
project: p.name,
});
settings.value = {
name: p.name,
theme: p.terminal.theme ?? "mocha",
icon: p.terminal.icon,
title: p.terminal.title ?? "",
todo,
workDirs,
};
} catch (e) {
error.value = String(e);
}
}
// Arbeitsordner schreiben direkt in die Projekt-settings.json
// (additionalDirectories + Edit-Permission) — kein Speichern-Schritt.
async function addWorkDir() {
const s = settings.value!;
const dir = await open({ directory: true, multiple: false });
if (typeof dir !== "string") return;
try {
await invoke("add_work_dir", { project: s.name, dir });
s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.name });
} catch (e) {
error.value = String(e);
}
}
async function removeWorkDir(dir: string) {
const s = settings.value!;
try {
await invoke("remove_work_dir", { project: s.name, dir });
s.workDirs = await invoke<string[]>("project_work_dirs", { project: s.name });
} catch (e) {
error.value = String(e);
}
}
async function pickIcon() {
const file = await open({
multiple: false,
@@ -588,6 +617,16 @@ onUnmounted(() => window.clearInterval(timer));
<span v-else class="icon-path">{{ $t("projects.defaultIcon") }}</span>
</span>
</label>
<label class="field">
{{ $t("projects.workDir") }}
<span class="workdirs">
<span v-for="wd in settings.workDirs" :key="wd" class="icon-row">
<span class="icon-path">{{ wd }}</span>
<button @click="removeWorkDir(wd)">{{ $t("projects.remove") }}</button>
</span>
<button @click="addWorkDir">{{ $t("projects.addWorkDir") }}</button>
</span>
</label>
<label class="field">
{{ $t("projects.todo") }}
<span class="checkline">
+4 -2
View File
@@ -51,8 +51,9 @@ const de = {
needsPool: "Kein Pool zugeordnet",
needsKey: "Pool hat keinen API-Key",
newProject: "+ Projekt",
addFolder: "+ Ordner",
addFolder: "Projekt importieren …",
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
addWorkDir: "Ordner hinzufügen …",
wizardTitle: "Neues Projekt",
name: "Name",
projectDir: "Projektordner",
@@ -173,8 +174,9 @@ const en: typeof de = {
needsPool: "No pool assigned",
needsKey: "Pool has no API key",
newProject: "+ Project",
addFolder: "+ Folder",
addFolder: "Import project …",
addFolderTitle: "Add an existing folder as a project",
addWorkDir: "Add folder …",
wizardTitle: "New project",
name: "Name",
projectDir: "Project folder",
+9 -2
View File
@@ -205,7 +205,7 @@ button.danger:hover:not(:disabled) {
}
.row-actions .w-action {
width: 9.5rem;
width: 7.5rem;
}
/* Tooltip-Träger um den Löschen-Button: ein disabled Button empfängt keine
@@ -351,7 +351,7 @@ col.col-actions {
width: 15rem;
}
col.col-actions-wide {
width: 16rem;
width: 21rem;
}
col.col-type {
width: 7rem;
@@ -583,6 +583,13 @@ td select {
gap: 0.5rem;
}
.workdirs {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
}
.icon-path {
font-family: var(--mono);
color: var(--overlay);