Projekt-Registry statt festem ~/claude-projects-Root: projects.json (Name → Pfad, ~-relativ) mit Alt-Layout-Fallback; Ordner frei mappbar (add/remove_project, Wizard-Ablageort); Session-Sync per Projekt-Repo (add/commit/pull --rebase/push) statt git-sync-Skript; README + GitHub-Release-Fassung
This commit is contained in:
@@ -1,5 +1,73 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
# ai-control
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
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.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
> Der gesamte Code dieses Projekts wurde von Claude (Claude Code) generiert.
|
||||
|
||||
## Zweck
|
||||
|
||||
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:
|
||||
|
||||
- **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). Ohne Registry gilt das Alt-Layout: Unterordner von `~/claude-projects` mit `.claude`-Ordner. 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.
|
||||
|
||||
## Projektaufbau
|
||||
|
||||
```
|
||||
├── index.html Haupt-UI (Projekt-/Pool-Verwaltung)
|
||||
├── terminal.html Terminal-Fenster (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)
|
||||
```
|
||||
|
||||
Zwei Prozessrollen aus einem 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.
|
||||
|
||||
Laufende Projekte werden über die Terminal-Prozesse erkannt (`pgrep` auf `--terminal <projekt>`), ohne Zustandsdatei.
|
||||
|
||||
## Tooling
|
||||
|
||||
| Bereich | 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) |
|
||||
| Secrets | keyring (macOS Keychain) |
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```sh
|
||||
./dev.sh # tauri dev mit Hot-Reload
|
||||
./build.sh # Release-Build; Bundle landet 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.
|
||||
|
||||
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.
|
||||
|
||||
## Konfigurationslayout
|
||||
|
||||
```
|
||||
~/.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
|
||||
|
||||
<projektordner>/ beliebiger Pfad, per Registry gemappt
|
||||
├── .claude/ Claude-Code-Projektkonfiguration
|
||||
└── ai-control.json Pool-Zuordnung + Terminal-Einstellungen
|
||||
```
|
||||
|
||||
+261
-101
@@ -16,19 +16,22 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
const APIKEY_FILE: &str = "apikey";
|
||||
const POOL_FILE: &str = "pool.json";
|
||||
const PROJECT_FILE: &str = "ai-control.json";
|
||||
const PROJECTS_FILE: &str = "projects.json";
|
||||
|
||||
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
|
||||
struct Paths {
|
||||
pub(crate) struct Paths {
|
||||
home: PathBuf,
|
||||
}
|
||||
|
||||
impl Paths {
|
||||
fn real() -> Self {
|
||||
pub(crate) fn real() -> Self {
|
||||
Paths {
|
||||
home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default-Root: Alt-Layout (Discovery ohne Registry) und Ablageort neuer
|
||||
/// Projekte ohne gewählten Zielordner.
|
||||
fn projects_dir(&self) -> PathBuf {
|
||||
self.home.join("claude-projects")
|
||||
}
|
||||
@@ -37,6 +40,10 @@ impl Paths {
|
||||
self.home.join(".config").join("ai-control")
|
||||
}
|
||||
|
||||
fn projects_file(&self) -> PathBuf {
|
||||
self.config_dir().join(PROJECTS_FILE)
|
||||
}
|
||||
|
||||
fn pools_dir(&self) -> PathBuf {
|
||||
self.config_dir().join("pools")
|
||||
}
|
||||
@@ -44,12 +51,93 @@ impl Paths {
|
||||
fn pool_dir(&self, pool: &str) -> PathBuf {
|
||||
self.pools_dir().join(pool)
|
||||
}
|
||||
}
|
||||
|
||||
fn project_config(&self, project: &str) -> PathBuf {
|
||||
self.projects_dir().join(project).join(PROJECT_FILE)
|
||||
// ---------- Projekt-Registry ----------
|
||||
|
||||
/// Pfad unterhalb von Home als "~/…" schreiben — Registry-Einträge im
|
||||
/// Home-Bereich bleiben damit maschinenübergreifend stabil.
|
||||
fn contract_home(paths: &Paths, p: &std::path::Path) -> String {
|
||||
match p.strip_prefix(&paths.home) {
|
||||
Ok(rest) => format!("~/{}", rest.display()),
|
||||
Err(_) => p.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry Name → Projektordner. Ohne projects.json gilt das Alt-Layout:
|
||||
/// Unterordner von ~/claude-projects mit .claude-Ordner.
|
||||
fn load_registry(paths: &Paths) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
|
||||
let file = paths.projects_file();
|
||||
if file.is_file() {
|
||||
let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
let map: std::collections::BTreeMap<String, String> =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
return Ok(
|
||||
map
|
||||
.into_iter()
|
||||
.map(|(name, p)| {
|
||||
let dir = expand_home(paths, &p);
|
||||
(name, dir)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
let Ok(entries) = fs::read_dir(paths.projects_dir()) else {
|
||||
return Ok(map); // kein Alt-Layout → keine Projekte
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
if entry.path().join(".claude").is_dir() {
|
||||
map.insert(entry.file_name().to_string_lossy().into_owned(), entry.path());
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn save_registry(
|
||||
paths: &Paths,
|
||||
reg: &std::collections::BTreeMap<String, PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
let contracted: std::collections::BTreeMap<&String, String> =
|
||||
reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
|
||||
let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(paths.config_dir()).map_err(|e| e.to_string())?;
|
||||
let file = paths.projects_file();
|
||||
fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
|
||||
}
|
||||
|
||||
/// Ordner eines registrierten Projekts.
|
||||
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
|
||||
load_registry(paths)?
|
||||
.remove(name)
|
||||
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))
|
||||
}
|
||||
|
||||
/// Nimmt ein Projekt in die Registry auf; materialisiert dabei auch die
|
||||
/// per Alt-Layout gefundenen Einträge in projects.json.
|
||||
fn register_project(paths: &Paths, name: &str, dir: &std::path::Path) -> Result<(), String> {
|
||||
let mut reg = load_registry(paths)?;
|
||||
if reg.contains_key(name) {
|
||||
return Err(format!("Projekt existiert bereits: {name}"));
|
||||
}
|
||||
reg.insert(name.to_string(), dir.to_path_buf());
|
||||
save_registry(paths, ®)
|
||||
}
|
||||
|
||||
/// Entfernt nur den Registry-Eintrag; der Projektordner bleibt.
|
||||
fn unregister_project(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
let mut reg = load_registry(paths)?;
|
||||
if reg.remove(name).is_none() {
|
||||
return Err(format!("Projekt nicht registriert: {name}"));
|
||||
}
|
||||
save_registry(paths, ®)
|
||||
}
|
||||
|
||||
fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
|
||||
Ok(project_dir(paths, project)?.join(PROJECT_FILE))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Project {
|
||||
name: String,
|
||||
@@ -145,8 +233,12 @@ fn set_sync_on_session_end_in(paths: &Paths, enabled: bool) -> Result<(), String
|
||||
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn is_git_repo(dir: &PathBuf) -> bool {
|
||||
dir.join(".git").exists()
|
||||
/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts.
|
||||
fn git_root(dir: &std::path::Path) -> Option<PathBuf> {
|
||||
dir
|
||||
.ancestors()
|
||||
.find(|a| a.join(".git").exists())
|
||||
.map(|a| a.to_path_buf())
|
||||
}
|
||||
|
||||
/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
|
||||
@@ -159,22 +251,45 @@ fn project_state(paths: &Paths) -> Vec<(String, bool)> {
|
||||
state
|
||||
}
|
||||
|
||||
/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das
|
||||
/// vorhandene git-sync (add/commit/pull --rebase/push, konfliktsicher).
|
||||
fn sync_session_context(home: &PathBuf, project: &str) {
|
||||
let git_sync = home.join("claude-projects/pool/bin/git-sync");
|
||||
let repo = home.join("claude-projects");
|
||||
match Command::new(&git_sync)
|
||||
.arg(&repo)
|
||||
.arg(format!("session-end sync: {project}"))
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => eprintln!(
|
||||
"session-end sync {project}: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => eprintln!("session-end sync {project}: git-sync nicht startbar: {e}"),
|
||||
/// Committet/pusht das Repo des Projekts nach einem Session-Ende:
|
||||
/// add -A → commit → pull --rebase → push. Nichts zu committen beendet still;
|
||||
/// jeder andere Fehler bricht die Kette mit Meldung ab.
|
||||
fn sync_session_context(repo: &std::path::Path, project: &str) {
|
||||
let run = |args: &[&str]| {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("git {}: {e}", args.join(" ")))
|
||||
};
|
||||
let msg = format!("session-end sync: {project}");
|
||||
let steps: [&[&str]; 4] = [
|
||||
&["add", "-A"],
|
||||
&["commit", "-m", &msg],
|
||||
&["pull", "--rebase"],
|
||||
&["push"],
|
||||
];
|
||||
for (i, args) in steps.iter().enumerate() {
|
||||
match run(args) {
|
||||
Ok(o) if o.status.success() => {}
|
||||
// commit ohne Änderungen: nichts zu syncen, kein Fehler.
|
||||
Ok(_) if i == 1 && run(&["diff", "--cached", "--quiet"]).is_ok_and(|d| d.status.success()) => {
|
||||
return;
|
||||
}
|
||||
Ok(o) => {
|
||||
eprintln!(
|
||||
"session-end sync {project}: git {} — {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("session-end sync {project}: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,14 +315,15 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
|
||||
})
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
// Nur syncen, wenn zugestimmt UND ein Git-Repo da ist — der Tray
|
||||
// verlangt kein Repo, er nutzt es nur, falls vorhanden.
|
||||
if !ended.is_empty()
|
||||
&& sync_on_session_end(&paths)
|
||||
&& is_git_repo(&paths.projects_dir())
|
||||
{
|
||||
// Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
|
||||
// der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden.
|
||||
if !ended.is_empty() && sync_on_session_end(&paths) {
|
||||
for project in ended {
|
||||
sync_session_context(&paths.home, project);
|
||||
if let Ok(dir) = project_dir(&paths, project) {
|
||||
if let Some(repo) = git_root(&dir) {
|
||||
sync_session_context(&repo, project);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Menü-Objekte gehören auf den Main-Thread.
|
||||
@@ -245,24 +361,16 @@ fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
|
||||
|
||||
fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||||
let mut projects = Vec::new();
|
||||
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
if !path.join(".claude").is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
for (name, dir) in load_registry(paths)? {
|
||||
let cfg = read_project_config_in(paths, &name)?;
|
||||
projects.push(Project {
|
||||
running: is_running(&name),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
path: dir.to_string_lossy().into_owned(),
|
||||
pool: cfg.pool,
|
||||
terminal: cfg.terminal,
|
||||
name,
|
||||
});
|
||||
}
|
||||
projects.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
@@ -273,19 +381,15 @@ fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||||
const TODO_FILE: &str = "OFFENE-PUNKTE.md";
|
||||
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
|
||||
|
||||
fn todo_hook_command(paths: &Paths, name: &str) -> String {
|
||||
fn todo_hook_command(dir: &std::path::Path) -> String {
|
||||
format!(
|
||||
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
|
||||
paths.projects_dir().join(name).join(TODO_FILE).display()
|
||||
dir.join(TODO_FILE).display()
|
||||
)
|
||||
}
|
||||
|
||||
fn settings_path(paths: &Paths, name: &str) -> PathBuf {
|
||||
paths
|
||||
.projects_dir()
|
||||
.join(name)
|
||||
.join(".claude")
|
||||
.join("settings.json")
|
||||
fn settings_path(dir: &std::path::Path) -> PathBuf {
|
||||
dir.join(".claude").join("settings.json")
|
||||
}
|
||||
|
||||
fn hook_is_todo(group: &serde_json::Value) -> bool {
|
||||
@@ -303,7 +407,7 @@ fn hook_is_todo(group: &serde_json::Value) -> bool {
|
||||
|
||||
fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(paths, name);
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
if !sp.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -320,7 +424,8 @@ fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
|
||||
|
||||
fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(paths, name);
|
||||
let dir = project_dir(paths, name)?;
|
||||
let sp = settings_path(&dir);
|
||||
if !sp.is_file() {
|
||||
return Err(format!("settings.json fehlt: {}", sp.display()));
|
||||
}
|
||||
@@ -346,11 +451,11 @@ fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
|
||||
0,
|
||||
serde_json::json!({
|
||||
"hooks": [
|
||||
{ "type": "command", "command": todo_hook_command(paths, name) }
|
||||
{ "type": "command", "command": todo_hook_command(&dir) }
|
||||
]
|
||||
}),
|
||||
);
|
||||
let todo_path = paths.projects_dir().join(name).join(TODO_FILE);
|
||||
let todo_path = dir.join(TODO_FILE);
|
||||
if !todo_path.is_file() {
|
||||
fs::write(&todo_path, TODO_SKELETON)
|
||||
.map_err(|e| format!("{}: {e}", todo_path.display()))?;
|
||||
@@ -371,11 +476,12 @@ fn expand_home(paths: &Paths, p: &str) -> PathBuf {
|
||||
|
||||
/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
|
||||
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
|
||||
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init —
|
||||
/// der Ordner ist Teil des einen claude-projects-Repos.
|
||||
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag.
|
||||
/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
|
||||
fn create_project_full_in(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
dir: Option<&str>,
|
||||
pool: Option<&str>,
|
||||
work_dir: Option<&str>,
|
||||
create_work_dir: bool,
|
||||
@@ -383,10 +489,19 @@ fn create_project_full_in(
|
||||
todo: bool,
|
||||
) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = paths.projects_dir().join(name);
|
||||
if dir.exists() {
|
||||
let dir = match dir {
|
||||
Some(d) => expand_home(paths, d),
|
||||
None => paths.projects_dir().join(name),
|
||||
};
|
||||
// Registry-Snapshot vor dem Anlegen — der Alt-Layout-Scan würde den frisch
|
||||
// angelegten Ordner sonst schon als bestehendes Projekt sehen.
|
||||
let mut reg = load_registry(paths)?;
|
||||
if reg.contains_key(name) {
|
||||
return Err(format!("Projekt existiert bereits: {name}"));
|
||||
}
|
||||
if dir.exists() {
|
||||
return Err(format!("Ordner existiert bereits: {}", dir.display()));
|
||||
}
|
||||
if let Some(pool) = pool {
|
||||
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
|
||||
return Err(format!("Pool existiert nicht: {pool}"));
|
||||
@@ -407,14 +522,15 @@ fn create_project_full_in(
|
||||
fs::create_dir_all(dir.join("memory")).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join(".gitignore"), ".ai-control-running\n").map_err(|e| e.to_string())?;
|
||||
|
||||
let mut allow = vec![format!("Edit(~/claude-projects/{name}/**)")];
|
||||
let contracted = contract_home(paths, &dir);
|
||||
let mut allow = vec![format!("Edit({contracted}/**)")];
|
||||
let mut additional: Vec<String> = Vec::new();
|
||||
if let Some(wd) = work_dir {
|
||||
allow.insert(0, format!("Edit({wd}/**)"));
|
||||
additional.push(wd.to_string());
|
||||
}
|
||||
let settings = serde_json::json!({
|
||||
"autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
|
||||
"autoMemoryDirectory": format!("{contracted}/memory"),
|
||||
"permissions": {
|
||||
"allow": allow,
|
||||
"additionalDirectories": additional,
|
||||
@@ -423,6 +539,8 @@ fn create_project_full_in(
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(dir.join(".claude").join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
|
||||
|
||||
reg.insert(name.to_string(), dir.clone());
|
||||
save_registry(paths, ®)?;
|
||||
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };
|
||||
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
||||
write_project_config_in(paths, name, &cfg)?;
|
||||
@@ -433,6 +551,21 @@ fn create_project_full_in(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername.
|
||||
fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
|
||||
let dir = expand_home(paths, path);
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Ordner nicht gefunden: {}", dir.display()));
|
||||
}
|
||||
let name = dir
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("kein Ordnername: {}", dir.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
check_name(&name)?;
|
||||
register_project(paths, &name, &dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
@@ -440,17 +573,19 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||
if dir.exists() {
|
||||
return Err(format!("Projekt existiert bereits: {name}"));
|
||||
}
|
||||
fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())
|
||||
fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())?;
|
||||
// Registrieren nur, wenn die Registry schon materialisiert ist — sonst
|
||||
// greift der Alt-Layout-Scan.
|
||||
if paths.projects_file().is_file() {
|
||||
register_project(paths, name, &dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
let sp = paths
|
||||
.projects_dir()
|
||||
.join(name)
|
||||
.join(".claude")
|
||||
.join("settings.json");
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
if !sp.is_file() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -469,11 +604,11 @@ fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String
|
||||
)
|
||||
}
|
||||
|
||||
/// Löscht den Projektordner unter claude-projects; optional auch die
|
||||
/// Löscht den Projektordner und den Registry-Eintrag; optional auch die
|
||||
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
|
||||
fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = paths.projects_dir().join(name);
|
||||
let dir = project_dir(paths, name)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Projekt nicht gefunden: {name}"));
|
||||
}
|
||||
@@ -483,11 +618,16 @@ fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Resul
|
||||
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
|
||||
}
|
||||
}
|
||||
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
|
||||
fs::remove_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
// Vor der Materialisierung gab es keinen Eintrag zu entfernen.
|
||||
if paths.projects_file().is_file() {
|
||||
unregister_project(paths, name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
|
||||
let cfg_path = paths.project_config(project);
|
||||
let cfg_path = project_config_path(paths, project)?;
|
||||
if !cfg_path.is_file() {
|
||||
return Ok(ProjectConfig::default());
|
||||
}
|
||||
@@ -502,7 +642,7 @@ fn write_project_config_in(
|
||||
cfg: &ProjectConfig,
|
||||
) -> Result<(), String> {
|
||||
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
|
||||
fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string())
|
||||
fs::write(project_config_path(paths, project)?, raw + "\n").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
|
||||
@@ -520,7 +660,7 @@ fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
|
||||
let mut cfg = read_project_config_in(paths, project)?;
|
||||
cfg.pool = None;
|
||||
if cfg.terminal.is_empty() {
|
||||
let cfg_path = paths.project_config(project);
|
||||
let cfg_path = project_config_path(paths, project)?;
|
||||
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
|
||||
}
|
||||
write_project_config_in(paths, project, &cfg)
|
||||
@@ -542,7 +682,7 @@ fn set_terminal_config_in(
|
||||
.unwrap_or("png")
|
||||
.to_lowercase();
|
||||
let name = format!("icon.{ext}");
|
||||
let dest = paths.projects_dir().join(project).join(&name);
|
||||
let dest = project_dir(paths, project)?.join(&name);
|
||||
if src != dest {
|
||||
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
}
|
||||
@@ -555,11 +695,11 @@ fn set_terminal_config_in(
|
||||
}
|
||||
|
||||
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im Projektordner.
|
||||
fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> PathBuf {
|
||||
fn resolve_icon_path(paths: &Paths, project: &str, icon: &str) -> Result<PathBuf, String> {
|
||||
if icon.starts_with('/') {
|
||||
PathBuf::from(icon)
|
||||
Ok(PathBuf::from(icon))
|
||||
} else {
|
||||
paths.projects_dir().join(project).join(icon)
|
||||
Ok(project_dir(paths, project)?.join(icon))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,7 +712,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, &project, &icon)?;
|
||||
let is_icns = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
@@ -625,7 +765,7 @@ fn menu_icon(
|
||||
}
|
||||
}
|
||||
if let Some(icon) = read_project_config_in(paths, project)?.terminal.icon {
|
||||
let src = resolve_icon_path(paths, project, &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"])
|
||||
@@ -661,20 +801,17 @@ pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
|
||||
/// Namen aller Projekte, die `pool` zugeordnet haben.
|
||||
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
|
||||
let mut users = Vec::new();
|
||||
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let cfg_path = entry.path().join(PROJECT_FILE);
|
||||
for (name, dir) in load_registry(paths)? {
|
||||
let cfg_path = dir.join(PROJECT_FILE);
|
||||
if !cfg_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
|
||||
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
|
||||
if cfg.pool.as_deref() == Some(pool) {
|
||||
users.push(entry.file_name().to_string_lossy().into_owned());
|
||||
users.push(name);
|
||||
}
|
||||
}
|
||||
users.sort();
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
@@ -1219,13 +1356,8 @@ fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
|
||||
|
||||
// kodierter Projektpfad → Projektname
|
||||
let mut names = HashMap::new();
|
||||
for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
if !entry.path().join(".claude").is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
names.insert(encode_project_path(&entry.path().to_string_lossy()), name);
|
||||
for (name, dir) in load_registry(paths)? {
|
||||
names.insert(encode_project_path(&dir.to_string_lossy()), name);
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
@@ -1291,6 +1423,7 @@ fn list_projects() -> Result<Vec<Project>, String> {
|
||||
#[tauri::command]
|
||||
fn create_project_full(
|
||||
name: String,
|
||||
dir: Option<String>,
|
||||
pool: Option<String>,
|
||||
work_dir: Option<String>,
|
||||
create_work_dir: bool,
|
||||
@@ -1300,6 +1433,7 @@ fn create_project_full(
|
||||
create_project_full_in(
|
||||
&Paths::real(),
|
||||
&name,
|
||||
dir.as_deref(),
|
||||
pool.as_deref(),
|
||||
work_dir.as_deref(),
|
||||
create_work_dir,
|
||||
@@ -1308,6 +1442,27 @@ fn create_project_full(
|
||||
)
|
||||
}
|
||||
|
||||
/// Bestehenden Ordner als Projekt aufnehmen (nur Registry-Eintrag).
|
||||
#[tauri::command]
|
||||
fn add_project(path: String) -> Result<(), String> {
|
||||
add_project_in(&Paths::real(), &path)
|
||||
}
|
||||
|
||||
/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
|
||||
#[tauri::command]
|
||||
fn remove_project(name: String) -> Result<(), String> {
|
||||
if is_running(&name) {
|
||||
return Err(format!("{name} läuft noch — erst beenden"));
|
||||
}
|
||||
let paths = Paths::real();
|
||||
// Alt-Layout erst materialisieren, sonst taucht das Projekt beim
|
||||
// nächsten Scan wieder auf.
|
||||
if !paths.projects_file().is_file() {
|
||||
save_registry(&paths, &load_registry(&paths)?)?;
|
||||
}
|
||||
unregister_project(&paths, &name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn todo_state(project: String) -> Result<bool, String> {
|
||||
todo_state_in(&Paths::real(), &project)
|
||||
@@ -1520,6 +1675,7 @@ pub fn run() {
|
||||
.icon
|
||||
.map(|i| {
|
||||
resolve_icon_path(&Paths::real(), &project, &i)
|
||||
.expect("Projekt nicht registriert")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
@@ -1655,6 +1811,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_projects,
|
||||
create_project_full,
|
||||
add_project,
|
||||
remove_project,
|
||||
delete_project,
|
||||
project_work_dirs,
|
||||
list_pools,
|
||||
@@ -2040,6 +2198,7 @@ mod tests {
|
||||
create_project_full_in(
|
||||
&p,
|
||||
"neu",
|
||||
None,
|
||||
Some(&kunde),
|
||||
Some("~/projects/neu"),
|
||||
true,
|
||||
@@ -2089,7 +2248,7 @@ mod tests {
|
||||
#[test]
|
||||
fn todo_zuschalten_und_abschalten() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, None, false, TerminalConfig::default(), false)
|
||||
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
assert!(!todo_state_in(&p, "proj").unwrap());
|
||||
|
||||
@@ -2101,7 +2260,7 @@ mod tests {
|
||||
// doppelt aktivieren erzeugt keinen zweiten Hook
|
||||
set_todo_in(&p, "proj", true).unwrap();
|
||||
let settings: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(settings_path(&p, "proj")).unwrap(),
|
||||
&fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
|
||||
@@ -2126,11 +2285,11 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false).unwrap();
|
||||
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false).unwrap();
|
||||
let dir = p.projects_dir().join("neu");
|
||||
assert!(dir.join(".claude").join("settings.json").is_file());
|
||||
// ohne Pool und Terminal-Config entsteht keine ai-control.json
|
||||
assert!(!p.project_config("neu").is_file());
|
||||
assert!(!project_config_path(&p, "neu").unwrap().is_file());
|
||||
let settings: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
|
||||
)
|
||||
@@ -2144,7 +2303,7 @@ mod tests {
|
||||
let p = tmp_paths();
|
||||
create_project_in(&p, "neu").unwrap();
|
||||
let err =
|
||||
create_project_full_in(&p, "neu", None, None, false, TerminalConfig::default(), false)
|
||||
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
@@ -2156,6 +2315,7 @@ mod tests {
|
||||
&p,
|
||||
"neu",
|
||||
None,
|
||||
None,
|
||||
Some("~/projects/gibtsnicht"),
|
||||
false,
|
||||
TerminalConfig::default(),
|
||||
@@ -2295,7 +2455,7 @@ mod tests {
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
delete_pool_in(&p, &store, &kunde).unwrap();
|
||||
assert!(!p.pool_dir(&kunde).exists());
|
||||
assert!(!p.project_config("proj").exists());
|
||||
assert!(!project_config_path(&p, "proj").unwrap().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2360,7 +2520,7 @@ mod tests {
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
|
||||
let cfg: ProjectConfig =
|
||||
serde_json::from_str(&fs::read_to_string(p.project_config("proj")).unwrap())
|
||||
serde_json::from_str(&fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
|
||||
|
||||
@@ -2382,7 +2542,7 @@ mod tests {
|
||||
create_project_in(&p, "proj").unwrap();
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
unassign_pool_in(&p, "proj").unwrap();
|
||||
assert!(!p.project_config("proj").exists());
|
||||
assert!(!project_config_path(&p, "proj").unwrap().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2404,7 +2564,7 @@ mod tests {
|
||||
assign_pool_in(&p, "proj", &privat).unwrap();
|
||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||
|
||||
let raw = fs::read_to_string(p.project_config("proj")).unwrap();
|
||||
let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap();
|
||||
let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(cfg, serde_json::json!({ "pool": kunde }));
|
||||
}
|
||||
@@ -2441,7 +2601,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_laesst_arbeitsordner() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
delete_project_in(&p, "proj", false).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
@@ -2451,7 +2611,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_mit_arbeitsordner() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
delete_project_in(&p, "proj", true).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
@@ -2461,7 +2621,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
|
||||
assert!(delete_project_in(&p, "proj", true).is_err());
|
||||
@@ -2470,7 +2630,7 @@ mod tests {
|
||||
#[test]
|
||||
fn projekt_arbeitsordner_auslesen() {
|
||||
let p = tmp_paths();
|
||||
create_project_full_in(&p, "proj", None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
|
||||
.unwrap();
|
||||
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
|
||||
// Projekt ohne settings.json → leer
|
||||
|
||||
@@ -2,7 +2,6 @@ use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
@@ -21,8 +20,7 @@ pub struct Terminals(pub Mutex<HashMap<String, Session>>);
|
||||
/// eigenes Dock-Icon bekommt.
|
||||
#[tauri::command]
|
||||
pub fn open_terminal(project: String) -> Result<(), String> {
|
||||
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
|
||||
let dir = PathBuf::from(home).join("claude-projects").join(&project);
|
||||
let dir = crate::project_dir(&crate::Paths::real(), &project)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Projektordner fehlt: {}", dir.display()));
|
||||
}
|
||||
@@ -123,8 +121,7 @@ pub fn term_start(
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
) -> Result<(), String> {
|
||||
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
|
||||
let cwd = PathBuf::from(home).join("claude-projects").join(&project);
|
||||
let cwd = crate::project_dir(&crate::Paths::real(), &project)?;
|
||||
|
||||
let pty = native_pty_system()
|
||||
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
||||
|
||||
@@ -205,6 +205,8 @@ async function saveSettings() {
|
||||
interface Wizard {
|
||||
name: string;
|
||||
pool: string;
|
||||
dirMode: "default" | "custom";
|
||||
dir: string | null;
|
||||
workDirMode: "none" | "default" | "custom";
|
||||
workDir: string | null;
|
||||
title: string;
|
||||
@@ -219,6 +221,8 @@ function openWizard() {
|
||||
wizard.value = {
|
||||
name: "",
|
||||
pool: "",
|
||||
dirMode: "default",
|
||||
dir: null,
|
||||
workDirMode: "none",
|
||||
workDir: null,
|
||||
title: "",
|
||||
@@ -227,6 +231,26 @@ function openWizard() {
|
||||
};
|
||||
}
|
||||
|
||||
// Bestehenden Ordner als Projekt aufnehmen — Name ist der Ordnername.
|
||||
async function addFolder() {
|
||||
const dir = await open({ directory: true, multiple: false });
|
||||
if (typeof dir !== "string") return;
|
||||
try {
|
||||
await invoke("add_project", { path: dir });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickProjectDir() {
|
||||
const dir = await open({ directory: true, multiple: false });
|
||||
if (typeof dir === "string") {
|
||||
wizard.value!.dir = dir;
|
||||
wizard.value!.dirMode = "custom";
|
||||
}
|
||||
}
|
||||
|
||||
async function pickWorkDir() {
|
||||
const dir = await open({ directory: true, multiple: false });
|
||||
if (typeof dir === "string") {
|
||||
@@ -248,6 +272,8 @@ async function createProject() {
|
||||
try {
|
||||
await invoke("create_project_full", {
|
||||
name,
|
||||
// custom: gewählter Ordner ist der Ablageort, das Projekt entsteht darin
|
||||
dir: w.dirMode === "custom" ? `${w.dir}/${name}` : null,
|
||||
pool: w.pool === "" ? null : w.pool,
|
||||
workDir,
|
||||
createWorkDir: w.workDirMode === "default",
|
||||
@@ -300,6 +326,19 @@ async function confirmDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
// Nur den Registry-Eintrag entfernen; der Ordner bleibt.
|
||||
async function unlinkProject() {
|
||||
const d = pendingDelete.value!;
|
||||
try {
|
||||
await invoke("remove_project", { name: d.name });
|
||||
pendingDelete.value = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
pendingDelete.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
let timer: number;
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
@@ -313,6 +352,9 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<button class="primary" @click="openWizard">
|
||||
{{ $t("projects.newProject") }}
|
||||
</button>
|
||||
<button :title="$t('projects.addFolderTitle')" @click="addFolder">
|
||||
{{ $t("projects.addFolder") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@@ -393,6 +435,28 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
{{ $t("projects.name") }}
|
||||
<input v-model="wizard.name" autofocus required />
|
||||
</label>
|
||||
<label class="field">
|
||||
{{ $t("projects.projectDir") }}
|
||||
<span class="icon-row">
|
||||
<select v-model="wizard.dirMode">
|
||||
<option value="default">
|
||||
~/claude-projects/{{ wizard.name.trim() || "…" }}
|
||||
</option>
|
||||
<option value="custom">{{ $t("projects.projectDirCustom") }}</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="wizard.dirMode === 'custom'"
|
||||
type="button"
|
||||
@click="pickProjectDir"
|
||||
>
|
||||
{{ $t("projects.chooseFolder") }}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
<label v-if="wizard.dirMode === 'custom' && wizard.dir" class="field">
|
||||
<span></span>
|
||||
<span class="icon-path">{{ wizard.dir }}/{{ wizard.name.trim() || "…" }}</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
{{ $t("projects.pool") }}
|
||||
<select v-model="wizard.pool">
|
||||
@@ -454,6 +518,7 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
class="primary"
|
||||
:disabled="
|
||||
wizard.name.trim() === '' ||
|
||||
(wizard.dirMode === 'custom' && !wizard.dir) ||
|
||||
(wizard.workDirMode === 'custom' && !wizard.workDir)
|
||||
"
|
||||
>
|
||||
@@ -479,10 +544,14 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
{{ $t("projects.workDirsStay") }}
|
||||
</p>
|
||||
</template>
|
||||
<p class="hint">{{ $t("projects.unlinkHint") }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" @click="pendingDelete = null">
|
||||
{{ $t("projects.cancel") }}
|
||||
</button>
|
||||
<button @click="unlinkProject">
|
||||
{{ $t("projects.unlink") }}
|
||||
</button>
|
||||
<button class="danger" @click="confirmDelete">
|
||||
{{ $t("projects.delete") }}
|
||||
</button>
|
||||
|
||||
+16
-2
@@ -51,8 +51,12 @@ const de = {
|
||||
needsPool: "Kein Pool zugeordnet",
|
||||
needsKey: "Pool hat keinen API-Key",
|
||||
newProject: "+ Projekt",
|
||||
addFolder: "+ Ordner",
|
||||
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
|
||||
wizardTitle: "Neues Projekt",
|
||||
name: "Name",
|
||||
projectDir: "Projektordner",
|
||||
projectDirCustom: "Ordner wählen …",
|
||||
workDir: "Arbeitsordner",
|
||||
workDirNone: "keiner",
|
||||
workDirCustom: "vorhandenen Ordner verknüpfen",
|
||||
@@ -64,9 +68,12 @@ const de = {
|
||||
deleteRunning: "Läuft noch — erst beenden",
|
||||
deleteTitle: "{name} löschen",
|
||||
deleteWarning:
|
||||
"Der Projektordner unter ~/claude-projects (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
|
||||
"Der Projektordner (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
|
||||
deleteWorkDirs: "Arbeitsordner mitlöschen:",
|
||||
workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
|
||||
unlink: "Nur aus Liste nehmen",
|
||||
unlinkHint:
|
||||
"Nur aus Liste nehmen entfernt den Eintrag; der Ordner bleibt unangetastet.",
|
||||
todo: "Todoliste",
|
||||
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
|
||||
},
|
||||
@@ -166,8 +173,12 @@ const en: typeof de = {
|
||||
needsPool: "No pool assigned",
|
||||
needsKey: "Pool has no API key",
|
||||
newProject: "+ Project",
|
||||
addFolder: "+ Folder",
|
||||
addFolderTitle: "Add an existing folder as a project",
|
||||
wizardTitle: "New project",
|
||||
name: "Name",
|
||||
projectDir: "Project folder",
|
||||
projectDirCustom: "Choose folder …",
|
||||
workDir: "Working folder",
|
||||
workDirNone: "none",
|
||||
workDirCustom: "link existing folder",
|
||||
@@ -179,9 +190,12 @@ const en: typeof de = {
|
||||
deleteRunning: "Still running — stop it first",
|
||||
deleteTitle: "Delete {name}",
|
||||
deleteWarning:
|
||||
"The project folder under ~/claude-projects (incl. memory/, settings and pool assignment) will be deleted permanently.",
|
||||
"The project folder (incl. memory/, settings and pool assignment) will be deleted permanently.",
|
||||
deleteWorkDirs: "Also delete working folders:",
|
||||
workDirsStay: "The working folders stay untouched.",
|
||||
unlink: "Remove from list only",
|
||||
unlinkHint:
|
||||
"Remove from list only drops the entry; the folder stays untouched.",
|
||||
todo: "Todo list",
|
||||
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user