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:
@@ -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.
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Tray menu | Session terminal |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
## 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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -2,6 +2,7 @@
|
||||
"name": "ai-control",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+168
-25
@@ -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,7 +837,33 @@ fn menu_icon(
|
||||
}
|
||||
}
|
||||
if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
|
||||
let src = resolve_icon_path(paths, project, &icon)?;
|
||||
// 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"])
|
||||
@@ -756,18 +877,7 @@ fn menu_icon(
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(tauri::image::Image::new_owned(canvas, W as u32, H as u32))
|
||||
tauri::image::Image::from_bytes(&bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user