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
|
||||||
|
```
|
||||||
|
|||||||
+259
-99
@@ -16,19 +16,22 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||||||
const APIKEY_FILE: &str = "apikey";
|
const APIKEY_FILE: &str = "apikey";
|
||||||
const POOL_FILE: &str = "pool.json";
|
const POOL_FILE: &str = "pool.json";
|
||||||
const PROJECT_FILE: &str = "ai-control.json";
|
const PROJECT_FILE: &str = "ai-control.json";
|
||||||
|
const PROJECTS_FILE: &str = "projects.json";
|
||||||
|
|
||||||
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
|
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
|
||||||
struct Paths {
|
pub(crate) struct Paths {
|
||||||
home: PathBuf,
|
home: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Paths {
|
impl Paths {
|
||||||
fn real() -> Self {
|
pub(crate) fn real() -> Self {
|
||||||
Paths {
|
Paths {
|
||||||
home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
|
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 {
|
fn projects_dir(&self) -> PathBuf {
|
||||||
self.home.join("claude-projects")
|
self.home.join("claude-projects")
|
||||||
}
|
}
|
||||||
@@ -37,6 +40,10 @@ impl Paths {
|
|||||||
self.home.join(".config").join("ai-control")
|
self.home.join(".config").join("ai-control")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn projects_file(&self) -> PathBuf {
|
||||||
|
self.config_dir().join(PROJECTS_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
fn pools_dir(&self) -> PathBuf {
|
fn pools_dir(&self) -> PathBuf {
|
||||||
self.config_dir().join("pools")
|
self.config_dir().join("pools")
|
||||||
}
|
}
|
||||||
@@ -44,10 +51,91 @@ impl Paths {
|
|||||||
fn pool_dir(&self, pool: &str) -> PathBuf {
|
fn pool_dir(&self, pool: &str) -> PathBuf {
|
||||||
self.pools_dir().join(pool)
|
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)]
|
#[derive(Serialize)]
|
||||||
@@ -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()))
|
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_git_repo(dir: &PathBuf) -> bool {
|
/// Nächstliegendes Git-Repo (Ordner mit .git) ab `dir` aufwärts.
|
||||||
dir.join(".git").exists()
|
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
|
/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
|
||||||
@@ -159,22 +251,45 @@ fn project_state(paths: &Paths) -> Vec<(String, bool)> {
|
|||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Committet/pusht das claude-projects-Repo nach einem Session-Ende über das
|
/// Committet/pusht das Repo des Projekts nach einem Session-Ende:
|
||||||
/// vorhandene git-sync (add/commit/pull --rebase/push, konfliktsicher).
|
/// add -A → commit → pull --rebase → push. Nichts zu committen beendet still;
|
||||||
fn sync_session_context(home: &PathBuf, project: &str) {
|
/// jeder andere Fehler bricht die Kette mit Meldung ab.
|
||||||
let git_sync = home.join("claude-projects/pool/bin/git-sync");
|
fn sync_session_context(repo: &std::path::Path, project: &str) {
|
||||||
let repo = home.join("claude-projects");
|
let run = |args: &[&str]| {
|
||||||
match Command::new(&git_sync)
|
Command::new("git")
|
||||||
.arg(&repo)
|
.arg("-C")
|
||||||
.arg(format!("session-end sync: {project}"))
|
.arg(repo)
|
||||||
|
.args(args)
|
||||||
.output()
|
.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() => {}
|
Ok(o) if o.status.success() => {}
|
||||||
Ok(o) => eprintln!(
|
// commit ohne Änderungen: nichts zu syncen, kein Fehler.
|
||||||
"session-end sync {project}: {}",
|
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()
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
),
|
);
|
||||||
Err(e) => eprintln!("session-end sync {project}: git-sync nicht startbar: {e}"),
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("session-end sync {project}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,14 +315,15 @@ fn spawn_session_watcher(app: tauri::AppHandle) {
|
|||||||
})
|
})
|
||||||
.map(|(name, _)| name)
|
.map(|(name, _)| name)
|
||||||
.collect();
|
.collect();
|
||||||
// Nur syncen, wenn zugestimmt UND ein Git-Repo da ist — der Tray
|
// Nur syncen, wenn zugestimmt UND das Projekt in einem Git-Repo liegt —
|
||||||
// verlangt kein Repo, er nutzt es nur, falls vorhanden.
|
// der Tray verlangt kein Repo, er nutzt es nur, falls vorhanden.
|
||||||
if !ended.is_empty()
|
if !ended.is_empty() && sync_on_session_end(&paths) {
|
||||||
&& sync_on_session_end(&paths)
|
|
||||||
&& is_git_repo(&paths.projects_dir())
|
|
||||||
{
|
|
||||||
for project in ended {
|
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.
|
// 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> {
|
fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
|
||||||
let mut projects = Vec::new();
|
let mut projects = Vec::new();
|
||||||
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
for (name, dir) in load_registry(paths)? {
|
||||||
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();
|
|
||||||
let cfg = read_project_config_in(paths, &name)?;
|
let cfg = read_project_config_in(paths, &name)?;
|
||||||
projects.push(Project {
|
projects.push(Project {
|
||||||
running: is_running(&name),
|
running: is_running(&name),
|
||||||
path: path.to_string_lossy().into_owned(),
|
path: dir.to_string_lossy().into_owned(),
|
||||||
pool: cfg.pool,
|
pool: cfg.pool,
|
||||||
terminal: cfg.terminal,
|
terminal: cfg.terminal,
|
||||||
name,
|
name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
projects.sort_by(|a, b| a.name.cmp(&b.name));
|
|
||||||
Ok(projects)
|
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_FILE: &str = "OFFENE-PUNKTE.md";
|
||||||
const TODO_SKELETON: &str = "# Offene Punkte — bei jedem Start prüfen und abhaken\n\nKeine offenen Punkte.\n";
|
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!(
|
format!(
|
||||||
"jq -Rs '{{systemMessage: ., hookSpecificOutput:{{hookEventName:\"SessionStart\", additionalContext: .}}}}' {}",
|
"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 {
|
fn settings_path(dir: &std::path::Path) -> PathBuf {
|
||||||
paths
|
dir.join(".claude").join("settings.json")
|
||||||
.projects_dir()
|
|
||||||
.join(name)
|
|
||||||
.join(".claude")
|
|
||||||
.join("settings.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hook_is_todo(group: &serde_json::Value) -> bool {
|
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> {
|
fn todo_state_in(paths: &Paths, name: &str) -> Result<bool, String> {
|
||||||
check_name(name)?;
|
check_name(name)?;
|
||||||
let sp = settings_path(paths, name);
|
let sp = settings_path(&project_dir(paths, name)?);
|
||||||
if !sp.is_file() {
|
if !sp.is_file() {
|
||||||
return Ok(false);
|
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> {
|
fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<(), String> {
|
||||||
check_name(name)?;
|
check_name(name)?;
|
||||||
let sp = settings_path(paths, name);
|
let dir = project_dir(paths, name)?;
|
||||||
|
let sp = settings_path(&dir);
|
||||||
if !sp.is_file() {
|
if !sp.is_file() {
|
||||||
return Err(format!("settings.json fehlt: {}", sp.display()));
|
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,
|
0,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"hooks": [
|
"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() {
|
if !todo_path.is_file() {
|
||||||
fs::write(&todo_path, TODO_SKELETON)
|
fs::write(&todo_path, TODO_SKELETON)
|
||||||
.map_err(|e| format!("{}: {e}", todo_path.display()))?;
|
.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
|
/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
|
||||||
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
|
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
|
||||||
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config. Kein git init —
|
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag.
|
||||||
/// der Ordner ist Teil des einen claude-projects-Repos.
|
/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
|
||||||
fn create_project_full_in(
|
fn create_project_full_in(
|
||||||
paths: &Paths,
|
paths: &Paths,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
dir: Option<&str>,
|
||||||
pool: Option<&str>,
|
pool: Option<&str>,
|
||||||
work_dir: Option<&str>,
|
work_dir: Option<&str>,
|
||||||
create_work_dir: bool,
|
create_work_dir: bool,
|
||||||
@@ -383,10 +489,19 @@ fn create_project_full_in(
|
|||||||
todo: bool,
|
todo: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
check_name(name)?;
|
check_name(name)?;
|
||||||
let dir = paths.projects_dir().join(name);
|
let dir = match dir {
|
||||||
if dir.exists() {
|
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}"));
|
return Err(format!("Projekt existiert bereits: {name}"));
|
||||||
}
|
}
|
||||||
|
if dir.exists() {
|
||||||
|
return Err(format!("Ordner existiert bereits: {}", dir.display()));
|
||||||
|
}
|
||||||
if let Some(pool) = pool {
|
if let Some(pool) = pool {
|
||||||
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
|
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
|
||||||
return Err(format!("Pool existiert nicht: {pool}"));
|
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::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())?;
|
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();
|
let mut additional: Vec<String> = Vec::new();
|
||||||
if let Some(wd) = work_dir {
|
if let Some(wd) = work_dir {
|
||||||
allow.insert(0, format!("Edit({wd}/**)"));
|
allow.insert(0, format!("Edit({wd}/**)"));
|
||||||
additional.push(wd.to_string());
|
additional.push(wd.to_string());
|
||||||
}
|
}
|
||||||
let settings = serde_json::json!({
|
let settings = serde_json::json!({
|
||||||
"autoMemoryDirectory": format!("~/claude-projects/{name}/memory"),
|
"autoMemoryDirectory": format!("{contracted}/memory"),
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": allow,
|
"allow": allow,
|
||||||
"additionalDirectories": additional,
|
"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())?;
|
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())?;
|
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 };
|
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal };
|
||||||
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
||||||
write_project_config_in(paths, name, &cfg)?;
|
write_project_config_in(paths, name, &cfg)?;
|
||||||
@@ -433,6 +551,21 @@ fn create_project_full_in(
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
||||||
check_name(name)?;
|
check_name(name)?;
|
||||||
@@ -440,17 +573,19 @@ fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
|
|||||||
if dir.exists() {
|
if dir.exists() {
|
||||||
return Err(format!("Projekt existiert bereits: {name}"));
|
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.
|
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
|
||||||
fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
|
fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
|
||||||
check_name(name)?;
|
check_name(name)?;
|
||||||
let sp = paths
|
let sp = settings_path(&project_dir(paths, name)?);
|
||||||
.projects_dir()
|
|
||||||
.join(name)
|
|
||||||
.join(".claude")
|
|
||||||
.join("settings.json");
|
|
||||||
if !sp.is_file() {
|
if !sp.is_file() {
|
||||||
return Ok(Vec::new());
|
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.
|
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
|
||||||
fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> {
|
fn delete_project_in(paths: &Paths, name: &str, delete_work_dirs: bool) -> Result<(), String> {
|
||||||
check_name(name)?;
|
check_name(name)?;
|
||||||
let dir = paths.projects_dir().join(name);
|
let dir = project_dir(paths, name)?;
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Err(format!("Projekt nicht gefunden: {name}"));
|
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(&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> {
|
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() {
|
if !cfg_path.is_file() {
|
||||||
return Ok(ProjectConfig::default());
|
return Ok(ProjectConfig::default());
|
||||||
}
|
}
|
||||||
@@ -502,7 +642,7 @@ fn write_project_config_in(
|
|||||||
cfg: &ProjectConfig,
|
cfg: &ProjectConfig,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_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> {
|
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)?;
|
let mut cfg = read_project_config_in(paths, project)?;
|
||||||
cfg.pool = None;
|
cfg.pool = None;
|
||||||
if cfg.terminal.is_empty() {
|
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()));
|
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
|
||||||
}
|
}
|
||||||
write_project_config_in(paths, project, &cfg)
|
write_project_config_in(paths, project, &cfg)
|
||||||
@@ -542,7 +682,7 @@ fn set_terminal_config_in(
|
|||||||
.unwrap_or("png")
|
.unwrap_or("png")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
let name = format!("icon.{ext}");
|
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 {
|
if src != dest {
|
||||||
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
|
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.
|
/// 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('/') {
|
if icon.starts_with('/') {
|
||||||
PathBuf::from(icon)
|
Ok(PathBuf::from(icon))
|
||||||
} else {
|
} 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 {
|
let Some(icon) = read_project_config_in(&paths, &project)?.terminal.icon else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let path = resolve_icon_path(&paths, &project, &icon);
|
let path = resolve_icon_path(&paths, &project, &icon)?;
|
||||||
let is_icns = path
|
let is_icns = path
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|e| e.to_str())
|
.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 {
|
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 tmp = std::env::temp_dir().join(format!("ai-control-tray-icon-{project}.png"));
|
||||||
let out = Command::new("sips")
|
let out = Command::new("sips")
|
||||||
.args(["-s", "format", "png", "-z", "36", "36"])
|
.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.
|
/// Namen aller Projekte, die `pool` zugeordnet haben.
|
||||||
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
|
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
|
||||||
let mut users = Vec::new();
|
let mut users = Vec::new();
|
||||||
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
|
for (name, dir) in load_registry(paths)? {
|
||||||
for entry in entries {
|
let cfg_path = dir.join(PROJECT_FILE);
|
||||||
let entry = entry.map_err(|e| e.to_string())?;
|
|
||||||
let cfg_path = entry.path().join(PROJECT_FILE);
|
|
||||||
if !cfg_path.is_file() {
|
if !cfg_path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
|
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())?;
|
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
|
||||||
if cfg.pool.as_deref() == Some(pool) {
|
if cfg.pool.as_deref() == Some(pool) {
|
||||||
users.push(entry.file_name().to_string_lossy().into_owned());
|
users.push(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
users.sort();
|
|
||||||
Ok(users)
|
Ok(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1219,13 +1356,8 @@ fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
|
|||||||
|
|
||||||
// kodierter Projektpfad → Projektname
|
// kodierter Projektpfad → Projektname
|
||||||
let mut names = HashMap::new();
|
let mut names = HashMap::new();
|
||||||
for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? {
|
for (name, dir) in load_registry(paths)? {
|
||||||
let entry = entry.map_err(|e| e.to_string())?;
|
names.insert(encode_project_path(&dir.to_string_lossy()), name);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
@@ -1291,6 +1423,7 @@ fn list_projects() -> Result<Vec<Project>, String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn create_project_full(
|
fn create_project_full(
|
||||||
name: String,
|
name: String,
|
||||||
|
dir: Option<String>,
|
||||||
pool: Option<String>,
|
pool: Option<String>,
|
||||||
work_dir: Option<String>,
|
work_dir: Option<String>,
|
||||||
create_work_dir: bool,
|
create_work_dir: bool,
|
||||||
@@ -1300,6 +1433,7 @@ fn create_project_full(
|
|||||||
create_project_full_in(
|
create_project_full_in(
|
||||||
&Paths::real(),
|
&Paths::real(),
|
||||||
&name,
|
&name,
|
||||||
|
dir.as_deref(),
|
||||||
pool.as_deref(),
|
pool.as_deref(),
|
||||||
work_dir.as_deref(),
|
work_dir.as_deref(),
|
||||||
create_work_dir,
|
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]
|
#[tauri::command]
|
||||||
fn todo_state(project: String) -> Result<bool, String> {
|
fn todo_state(project: String) -> Result<bool, String> {
|
||||||
todo_state_in(&Paths::real(), &project)
|
todo_state_in(&Paths::real(), &project)
|
||||||
@@ -1520,6 +1675,7 @@ pub fn run() {
|
|||||||
.icon
|
.icon
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
resolve_icon_path(&Paths::real(), &project, &i)
|
resolve_icon_path(&Paths::real(), &project, &i)
|
||||||
|
.expect("Projekt nicht registriert")
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned()
|
.into_owned()
|
||||||
});
|
});
|
||||||
@@ -1655,6 +1811,8 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
list_projects,
|
list_projects,
|
||||||
create_project_full,
|
create_project_full,
|
||||||
|
add_project,
|
||||||
|
remove_project,
|
||||||
delete_project,
|
delete_project,
|
||||||
project_work_dirs,
|
project_work_dirs,
|
||||||
list_pools,
|
list_pools,
|
||||||
@@ -2040,6 +2198,7 @@ mod tests {
|
|||||||
create_project_full_in(
|
create_project_full_in(
|
||||||
&p,
|
&p,
|
||||||
"neu",
|
"neu",
|
||||||
|
None,
|
||||||
Some(&kunde),
|
Some(&kunde),
|
||||||
Some("~/projects/neu"),
|
Some("~/projects/neu"),
|
||||||
true,
|
true,
|
||||||
@@ -2089,7 +2248,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn todo_zuschalten_und_abschalten() {
|
fn todo_zuschalten_und_abschalten() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
assert!(!todo_state_in(&p, "proj").unwrap());
|
assert!(!todo_state_in(&p, "proj").unwrap());
|
||||||
|
|
||||||
@@ -2101,7 +2260,7 @@ mod tests {
|
|||||||
// doppelt aktivieren erzeugt keinen zweiten Hook
|
// doppelt aktivieren erzeugt keinen zweiten Hook
|
||||||
set_todo_in(&p, "proj", true).unwrap();
|
set_todo_in(&p, "proj", true).unwrap();
|
||||||
let settings: serde_json::Value = serde_json::from_str(
|
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();
|
.unwrap();
|
||||||
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
|
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
|
||||||
@@ -2126,11 +2285,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
|
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
|
||||||
let p = tmp_paths();
|
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");
|
let dir = p.projects_dir().join("neu");
|
||||||
assert!(dir.join(".claude").join("settings.json").is_file());
|
assert!(dir.join(".claude").join("settings.json").is_file());
|
||||||
// ohne Pool und Terminal-Config entsteht keine ai-control.json
|
// 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(
|
let settings: serde_json::Value = serde_json::from_str(
|
||||||
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
|
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
|
||||||
)
|
)
|
||||||
@@ -2144,7 +2303,7 @@ mod tests {
|
|||||||
let p = tmp_paths();
|
let p = tmp_paths();
|
||||||
create_project_in(&p, "neu").unwrap();
|
create_project_in(&p, "neu").unwrap();
|
||||||
let err =
|
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();
|
.unwrap_err();
|
||||||
assert!(err.contains("existiert bereits"));
|
assert!(err.contains("existiert bereits"));
|
||||||
}
|
}
|
||||||
@@ -2156,6 +2315,7 @@ mod tests {
|
|||||||
&p,
|
&p,
|
||||||
"neu",
|
"neu",
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some("~/projects/gibtsnicht"),
|
Some("~/projects/gibtsnicht"),
|
||||||
false,
|
false,
|
||||||
TerminalConfig::default(),
|
TerminalConfig::default(),
|
||||||
@@ -2295,7 +2455,7 @@ mod tests {
|
|||||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||||
delete_pool_in(&p, &store, &kunde).unwrap();
|
delete_pool_in(&p, &store, &kunde).unwrap();
|
||||||
assert!(!p.pool_dir(&kunde).exists());
|
assert!(!p.pool_dir(&kunde).exists());
|
||||||
assert!(!p.project_config("proj").exists());
|
assert!(!project_config_path(&p, "proj").unwrap().exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2360,7 +2520,7 @@ mod tests {
|
|||||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||||
|
|
||||||
let cfg: ProjectConfig =
|
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();
|
.unwrap();
|
||||||
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
|
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
|
||||||
|
|
||||||
@@ -2382,7 +2542,7 @@ mod tests {
|
|||||||
create_project_in(&p, "proj").unwrap();
|
create_project_in(&p, "proj").unwrap();
|
||||||
assign_pool_in(&p, "proj", &kunde).unwrap();
|
assign_pool_in(&p, "proj", &kunde).unwrap();
|
||||||
unassign_pool_in(&p, "proj").unwrap();
|
unassign_pool_in(&p, "proj").unwrap();
|
||||||
assert!(!p.project_config("proj").exists());
|
assert!(!project_config_path(&p, "proj").unwrap().exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2404,7 +2564,7 @@ mod tests {
|
|||||||
assign_pool_in(&p, "proj", &privat).unwrap();
|
assign_pool_in(&p, "proj", &privat).unwrap();
|
||||||
assign_pool_in(&p, "proj", &kunde).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();
|
let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||||
assert_eq!(cfg, serde_json::json!({ "pool": kunde }));
|
assert_eq!(cfg, serde_json::json!({ "pool": kunde }));
|
||||||
}
|
}
|
||||||
@@ -2441,7 +2601,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projekt_loeschen_laesst_arbeitsordner() {
|
fn projekt_loeschen_laesst_arbeitsordner() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
delete_project_in(&p, "proj", false).unwrap();
|
delete_project_in(&p, "proj", false).unwrap();
|
||||||
assert!(!p.projects_dir().join("proj").exists());
|
assert!(!p.projects_dir().join("proj").exists());
|
||||||
@@ -2451,7 +2611,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projekt_loeschen_mit_arbeitsordner() {
|
fn projekt_loeschen_mit_arbeitsordner() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
delete_project_in(&p, "proj", true).unwrap();
|
delete_project_in(&p, "proj", true).unwrap();
|
||||||
assert!(!p.projects_dir().join("proj").exists());
|
assert!(!p.projects_dir().join("proj").exists());
|
||||||
@@ -2461,7 +2621,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
|
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
|
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
|
||||||
assert!(delete_project_in(&p, "proj", true).is_err());
|
assert!(delete_project_in(&p, "proj", true).is_err());
|
||||||
@@ -2470,7 +2630,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn projekt_arbeitsordner_auslesen() {
|
fn projekt_arbeitsordner_auslesen() {
|
||||||
let p = tmp_paths();
|
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();
|
.unwrap();
|
||||||
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
|
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
|
||||||
// Projekt ohne settings.json → leer
|
// 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 portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
@@ -21,8 +20,7 @@ pub struct Terminals(pub Mutex<HashMap<String, Session>>);
|
|||||||
/// eigenes Dock-Icon bekommt.
|
/// eigenes Dock-Icon bekommt.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn open_terminal(project: String) -> Result<(), String> {
|
pub fn open_terminal(project: String) -> Result<(), String> {
|
||||||
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
|
let dir = crate::project_dir(&crate::Paths::real(), &project)?;
|
||||||
let dir = PathBuf::from(home).join("claude-projects").join(&project);
|
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Err(format!("Projektordner fehlt: {}", dir.display()));
|
return Err(format!("Projektordner fehlt: {}", dir.display()));
|
||||||
}
|
}
|
||||||
@@ -123,8 +121,7 @@ pub fn term_start(
|
|||||||
rows: u16,
|
rows: u16,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
|
let cwd = crate::project_dir(&crate::Paths::real(), &project)?;
|
||||||
let cwd = PathBuf::from(home).join("claude-projects").join(&project);
|
|
||||||
|
|
||||||
let pty = native_pty_system()
|
let pty = native_pty_system()
|
||||||
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ async function saveSettings() {
|
|||||||
interface Wizard {
|
interface Wizard {
|
||||||
name: string;
|
name: string;
|
||||||
pool: string;
|
pool: string;
|
||||||
|
dirMode: "default" | "custom";
|
||||||
|
dir: string | null;
|
||||||
workDirMode: "none" | "default" | "custom";
|
workDirMode: "none" | "default" | "custom";
|
||||||
workDir: string | null;
|
workDir: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -219,6 +221,8 @@ function openWizard() {
|
|||||||
wizard.value = {
|
wizard.value = {
|
||||||
name: "",
|
name: "",
|
||||||
pool: "",
|
pool: "",
|
||||||
|
dirMode: "default",
|
||||||
|
dir: null,
|
||||||
workDirMode: "none",
|
workDirMode: "none",
|
||||||
workDir: null,
|
workDir: null,
|
||||||
title: "",
|
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() {
|
async function pickWorkDir() {
|
||||||
const dir = await open({ directory: true, multiple: false });
|
const dir = await open({ directory: true, multiple: false });
|
||||||
if (typeof dir === "string") {
|
if (typeof dir === "string") {
|
||||||
@@ -248,6 +272,8 @@ async function createProject() {
|
|||||||
try {
|
try {
|
||||||
await invoke("create_project_full", {
|
await invoke("create_project_full", {
|
||||||
name,
|
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,
|
pool: w.pool === "" ? null : w.pool,
|
||||||
workDir,
|
workDir,
|
||||||
createWorkDir: w.workDirMode === "default",
|
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;
|
let timer: number;
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refresh();
|
refresh();
|
||||||
@@ -313,6 +352,9 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
<button class="primary" @click="openWizard">
|
<button class="primary" @click="openWizard">
|
||||||
{{ $t("projects.newProject") }}
|
{{ $t("projects.newProject") }}
|
||||||
</button>
|
</button>
|
||||||
|
<button :title="$t('projects.addFolderTitle')" @click="addFolder">
|
||||||
|
{{ $t("projects.addFolder") }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
@@ -393,6 +435,28 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
{{ $t("projects.name") }}
|
{{ $t("projects.name") }}
|
||||||
<input v-model="wizard.name" autofocus required />
|
<input v-model="wizard.name" autofocus required />
|
||||||
</label>
|
</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">
|
<label class="field">
|
||||||
{{ $t("projects.pool") }}
|
{{ $t("projects.pool") }}
|
||||||
<select v-model="wizard.pool">
|
<select v-model="wizard.pool">
|
||||||
@@ -454,6 +518,7 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
class="primary"
|
class="primary"
|
||||||
:disabled="
|
:disabled="
|
||||||
wizard.name.trim() === '' ||
|
wizard.name.trim() === '' ||
|
||||||
|
(wizard.dirMode === 'custom' && !wizard.dir) ||
|
||||||
(wizard.workDirMode === 'custom' && !wizard.workDir)
|
(wizard.workDirMode === 'custom' && !wizard.workDir)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -479,10 +544,14 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
{{ $t("projects.workDirsStay") }}
|
{{ $t("projects.workDirsStay") }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
<p class="hint">{{ $t("projects.unlinkHint") }}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" @click="pendingDelete = null">
|
<button type="button" @click="pendingDelete = null">
|
||||||
{{ $t("projects.cancel") }}
|
{{ $t("projects.cancel") }}
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="unlinkProject">
|
||||||
|
{{ $t("projects.unlink") }}
|
||||||
|
</button>
|
||||||
<button class="danger" @click="confirmDelete">
|
<button class="danger" @click="confirmDelete">
|
||||||
{{ $t("projects.delete") }}
|
{{ $t("projects.delete") }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+16
-2
@@ -51,8 +51,12 @@ const de = {
|
|||||||
needsPool: "Kein Pool zugeordnet",
|
needsPool: "Kein Pool zugeordnet",
|
||||||
needsKey: "Pool hat keinen API-Key",
|
needsKey: "Pool hat keinen API-Key",
|
||||||
newProject: "+ Projekt",
|
newProject: "+ Projekt",
|
||||||
|
addFolder: "+ Ordner",
|
||||||
|
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
|
||||||
wizardTitle: "Neues Projekt",
|
wizardTitle: "Neues Projekt",
|
||||||
name: "Name",
|
name: "Name",
|
||||||
|
projectDir: "Projektordner",
|
||||||
|
projectDirCustom: "Ordner wählen …",
|
||||||
workDir: "Arbeitsordner",
|
workDir: "Arbeitsordner",
|
||||||
workDirNone: "keiner",
|
workDirNone: "keiner",
|
||||||
workDirCustom: "vorhandenen Ordner verknüpfen",
|
workDirCustom: "vorhandenen Ordner verknüpfen",
|
||||||
@@ -64,9 +68,12 @@ const de = {
|
|||||||
deleteRunning: "Läuft noch — erst beenden",
|
deleteRunning: "Läuft noch — erst beenden",
|
||||||
deleteTitle: "{name} löschen",
|
deleteTitle: "{name} löschen",
|
||||||
deleteWarning:
|
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:",
|
deleteWorkDirs: "Arbeitsordner mitlöschen:",
|
||||||
workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
|
workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
|
||||||
|
unlink: "Nur aus Liste nehmen",
|
||||||
|
unlinkHint:
|
||||||
|
"Nur aus Liste nehmen entfernt den Eintrag; der Ordner bleibt unangetastet.",
|
||||||
todo: "Todoliste",
|
todo: "Todoliste",
|
||||||
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
|
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
|
||||||
},
|
},
|
||||||
@@ -166,8 +173,12 @@ const en: typeof de = {
|
|||||||
needsPool: "No pool assigned",
|
needsPool: "No pool assigned",
|
||||||
needsKey: "Pool has no API key",
|
needsKey: "Pool has no API key",
|
||||||
newProject: "+ Project",
|
newProject: "+ Project",
|
||||||
|
addFolder: "+ Folder",
|
||||||
|
addFolderTitle: "Add an existing folder as a project",
|
||||||
wizardTitle: "New project",
|
wizardTitle: "New project",
|
||||||
name: "Name",
|
name: "Name",
|
||||||
|
projectDir: "Project folder",
|
||||||
|
projectDirCustom: "Choose folder …",
|
||||||
workDir: "Working folder",
|
workDir: "Working folder",
|
||||||
workDirNone: "none",
|
workDirNone: "none",
|
||||||
workDirCustom: "link existing folder",
|
workDirCustom: "link existing folder",
|
||||||
@@ -179,9 +190,12 @@ const en: typeof de = {
|
|||||||
deleteRunning: "Still running — stop it first",
|
deleteRunning: "Still running — stop it first",
|
||||||
deleteTitle: "Delete {name}",
|
deleteTitle: "Delete {name}",
|
||||||
deleteWarning:
|
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:",
|
deleteWorkDirs: "Also delete working folders:",
|
||||||
workDirsStay: "The working folders stay untouched.",
|
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",
|
todo: "Todo list",
|
||||||
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
|
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user