Skip to content

Commit 9b9e8c8

Browse files
committed
feat: project groups (collapsible folders in the sidebar project list)
Projects can carry an optional UI-only group label. Grouped projects render under collapsible folder headers in the sidebar with drag and drop (into/out of/within folders, header drag to reorder groups), context-menu management, aggregated attention/done dots on collapsed folders, and localStorage-persisted collapse state. No filesystem effect; existing projects.json files load unchanged.
1 parent 8920e96 commit 9b9e8c8

13 files changed

Lines changed: 1172 additions & 45 deletions

File tree

docs/data-model.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Three directories, different owners:
99

1010
## Entities
1111

12-
- **Project** (`projects.json`, single JSON array) — git repo path + scripts + `preview_url` template + `files_to_copy` globs + `default_cli`.
12+
- **Project** (`projects.json`, single JSON array) — git repo path + scripts + `preview_url` template + `files_to_copy` globs + `default_cli` + optional `group` label (UI-only collapsible folder in the sidebar; no filesystem effect; a group exists iff ≥1 project carries the label. All group reads go through `groupOf()` in `src/lib/projectGroups.ts`, THE normalization point: trim + ALL-CAPS, so mixed-case labels on disk converge to one group. Collapse state + folder color live in `localStorage` keyed by normalized name, pruned when a group disappears).
1313
- **Task** (`tasks/<uuid>.json`) — git worktree branched from project's `base_branch`. Worktrees live at `~/termic/tasks/<project>/<name>/`. `is_main_checkout=true` tasks point at the project's live checkout (no worktree, archive skips `rm -rf`).
1414
- **Settings** (`settings.json`) — `repos_dir`, `welcomed`, `agents[]` (claude/gemini/codex defaults + customs; each has `command`/`args`/`yolo_args`/`runtime_yolo_command`). Defaults seeded if `agents` is empty. `schema_version` gates one-time on-disk migrations.
1515
- **Tab** (per task, in `useApp`) — `terminal` (PTY running a CLI), `edit` (CodeMirror), `diff` (vs HEAD). PTYs die with the app.

src-tauri/src/lib.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ pub struct Project {
121121
/// existing project + the `Default` impl stay git-backed.
122122
#[serde(default)]
123123
pub non_git: bool,
124+
125+
/// UI-only sidebar group label. Projects sharing the same non-empty
126+
/// value render under one collapsible folder header in the project
127+
/// list. Purely presentational — no effect on paths, git, or
128+
/// workspaces. `None` / empty = ungrouped (renders at the top level).
129+
#[serde(default, skip_serializing_if = "Option::is_none")]
130+
pub group: Option<String>,
124131
}
125132

126133
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
@@ -1725,6 +1732,8 @@ fn project_add(root_path: String, non_git: Option<bool>) -> Result<Project, Stri
17251732
members: Vec::new(),
17261733
spotlight_enabled: false,
17271734
non_git,
1735+
// New projects start ungrouped; grouping is a sidebar action.
1736+
group: None,
17281737
};
17291738
list.push(p.clone());
17301739
save_projects(&list).map_err(|e| e.to_string())?;
@@ -1900,6 +1909,7 @@ fn project_add_multi(root_path: String, name: String, members: Vec<ProjectMember
19001909
members,
19011910
spotlight_enabled: false,
19021911
non_git,
1912+
group: None,
19031913
};
19041914
list.push(p.clone());
19051915
save_projects(&list).map_err(|e| e.to_string())?;
@@ -1962,6 +1972,26 @@ fn project_reorder(ids: Vec<String>) -> Result<(), String> {
19621972
save_projects(&out).map_err(|e| e.to_string())
19631973
}
19641974

1975+
/// Set / clear the UI-only sidebar group label on a batch of projects in
1976+
/// ONE atomic projects.json write. Group rename / dissolve touch every
1977+
/// member — doing this per-project from the frontend could fail halfway
1978+
/// and leave a group half-renamed on disk, and full `project_update`s
1979+
/// would clobber concurrent edits to unrelated fields.
1980+
#[tauri::command]
1981+
fn project_set_group(ids: Vec<String>, group: Option<String>) -> Result<(), String> {
1982+
let group = group.and_then(|g| {
1983+
let t = g.trim().to_string();
1984+
if t.is_empty() { None } else { Some(t) }
1985+
});
1986+
let mut list = load_projects();
1987+
for p in list.iter_mut() {
1988+
if ids.contains(&p.id) {
1989+
p.group = group.clone();
1990+
}
1991+
}
1992+
save_projects(&list).map_err(|e| e.to_string())
1993+
}
1994+
19651995
#[tauri::command]
19661996
fn project_update(p: Project) -> Result<(), String> {
19671997
let mut list = load_projects();
@@ -8247,7 +8277,7 @@ pub fn run() {
82478277
Ok(())
82488278
})
82498279
.invoke_handler(tauri::generate_handler![
8250-
projects_list, project_add, project_add_multi, project_set_members, project_update, project_remove, project_reorder,
8280+
projects_list, project_add, project_add_multi, project_set_members, project_update, project_remove, project_reorder, project_set_group,
82518281
tasks_list, task_create, task_create_multi, task_open_repo, task_importable_worktrees, task_import_worktree, task_archive, task_set_cli, task_set_custom_command, task_set_resume_override, task_set_sandbox, task_set_yolo,
82528282
sandbox_available, sandbox_deny_counts, sandbox_recent_denied_hosts, sandbox_recent_denied_paths, sandbox_access_counts, sandbox_recent_access_hosts, sandbox_recent_access_paths, sandbox_set_monitor_filters, task_sandbox_add_allowed_host, task_sandbox_add_allowed_path, task_sandbox_remove_allowed_path, agent_sandbox_add_allowed_path, agent_sandbox_add_allowed_host, task_recent_denials,
82538283
repo_config_load, repo_config_load_at, repo_config_save, repo_config_scaffold, repo_config_add_allowed_host, repo_config_add_allowed_path,

0 commit comments

Comments
 (0)