Skip to content

Commit b9f6384

Browse files
committed
Implement profile management features: add profile selection, saving, and loading functionality; enhance workspace settings with profile support; update UI components for profile handling.
1 parent 9b75f08 commit b9f6384

File tree

6 files changed

+642
-52
lines changed

6 files changed

+642
-52
lines changed

src/core/workspace.rs

Lines changed: 157 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,55 @@ use std::{
55

66
use serde::{Deserialize, Serialize};
77

8-
/// On-disk workspace settings for a project folder.
9-
/// Stored at `<project>/.stitchworkspace/workspace.json`.
8+
/* ============================ Workspace settings ============================ */
9+
1010
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1111
pub struct WorkspaceSettings {
12-
pub version: u32, // for future migrations; currently 1
12+
pub version: u32,
1313
pub ext_filter: String,
1414
pub exclude_dirs: String,
1515
pub exclude_files: String,
1616
pub remove_prefix: String,
1717
pub remove_regex: String,
1818
pub hierarchy_only: bool,
1919
pub dirs_only: bool,
20+
21+
/// Optional name of the currently-selected profile (if any).
22+
#[serde(default)]
23+
pub current_profile: Option<String>,
24+
}
25+
26+
/* ================================ Profiles ================================= */
27+
28+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29+
pub enum ProfileScope {
30+
Shared,
31+
Local,
32+
}
33+
34+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35+
pub struct ProfileSelection {
36+
/// Project-relative path using forward slashes.
37+
pub path: String,
38+
pub state: bool,
39+
}
40+
41+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42+
pub struct Profile {
43+
pub name: String,
44+
pub settings: WorkspaceSettings,
45+
/// Explicit on/off checks captured relative to project root.
46+
pub explicit: Vec<ProfileSelection>,
2047
}
2148

49+
#[derive(Debug, Clone)]
50+
pub struct ProfileMeta {
51+
pub name: String,
52+
pub scope: ProfileScope,
53+
}
54+
55+
/* ========================= Paths & basic workspace ========================= */
56+
2257
pub fn workspace_dir(project_root: &Path) -> PathBuf {
2358
project_root.join(".stitchworkspace")
2459
}
@@ -27,7 +62,6 @@ pub fn workspace_file(project_root: &Path) -> PathBuf {
2762
workspace_dir(project_root).join("workspace.json")
2863
}
2964

30-
/// Ensure `.stitchworkspace/` exists; return its path.
3165
pub fn ensure_workspace_dir(project_root: &Path) -> io::Result<PathBuf> {
3266
let dir = workspace_dir(project_root);
3367
if !dir.exists() {
@@ -36,14 +70,55 @@ pub fn ensure_workspace_dir(project_root: &Path) -> io::Result<PathBuf> {
3670
Ok(dir)
3771
}
3872

39-
/// Try to load settings; returns `None` if file is missing or invalid.
73+
/* ============================ Profiles locations ============================ */
74+
75+
fn profiles_shared_dir(project_root: &Path) -> PathBuf {
76+
workspace_dir(project_root).join("profiles")
77+
}
78+
79+
fn profiles_local_dir(project_root: &Path) -> PathBuf {
80+
workspace_dir(project_root).join("local").join("profiles")
81+
}
82+
83+
pub fn ensure_profiles_dirs(project_root: &Path) -> io::Result<()> {
84+
fs::create_dir_all(profiles_shared_dir(project_root))?;
85+
fs::create_dir_all(profiles_local_dir(project_root))?;
86+
Ok(())
87+
}
88+
89+
fn sanitize_profile_name(name: &str) -> String {
90+
// keep it simple & predictable for file names
91+
let mut s = name.trim().to_string();
92+
if s.is_empty() {
93+
s.push_str("unnamed");
94+
}
95+
s.chars()
96+
.map(|c| {
97+
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ' {
98+
c
99+
} else {
100+
'_'
101+
}
102+
})
103+
.collect()
104+
}
105+
106+
fn profile_path(project_root: &Path, scope: ProfileScope, name: &str) -> PathBuf {
107+
let base = match scope {
108+
ProfileScope::Shared => profiles_shared_dir(project_root),
109+
ProfileScope::Local => profiles_local_dir(project_root),
110+
};
111+
base.join(format!("{}.json", sanitize_profile_name(name)))
112+
}
113+
114+
/* =============================== Workspace IO ============================== */
115+
40116
pub fn load_workspace(project_root: &Path) -> Option<WorkspaceSettings> {
41117
let path = workspace_file(project_root);
42118
let data = fs::read(&path).ok()?;
43119
serde_json::from_slice::<WorkspaceSettings>(&data).ok()
44120
}
45121

46-
/// Save settings atomically to `workspace.json`.
47122
pub fn save_workspace(project_root: &Path, settings: &WorkspaceSettings) -> io::Result<()> {
48123
ensure_workspace_dir(project_root)?;
49124

@@ -53,7 +128,82 @@ pub fn save_workspace(project_root: &Path, settings: &WorkspaceSettings) -> io::
53128
let data = serde_json::to_vec_pretty(settings).map_err(|e| io::Error::other(e.to_string()))?;
54129

55130
fs::write(&tmp, data)?;
56-
// Atomic on most platforms when same directory
57131
fs::rename(&tmp, &path)?;
58132
Ok(())
59133
}
134+
135+
/* =============================== Profiles IO =============================== */
136+
137+
pub fn save_profile(
138+
project_root: &Path,
139+
profile: &Profile,
140+
scope: ProfileScope,
141+
) -> io::Result<()> {
142+
ensure_profiles_dirs(project_root)?;
143+
let path = profile_path(project_root, scope, &profile.name);
144+
let tmp = path.with_extension("tmp");
145+
let data = serde_json::to_vec_pretty(profile).map_err(|e| io::Error::other(e.to_string()))?;
146+
fs::write(&tmp, data)?;
147+
fs::rename(&tmp, &path)?;
148+
Ok(())
149+
}
150+
151+
/// Returns (Profile, Scope) preferring Local if both exist.
152+
pub fn load_profile(project_root: &Path, name: &str) -> Option<(Profile, ProfileScope)> {
153+
let local = profile_path(project_root, ProfileScope::Local, name);
154+
if let Ok(bytes) = fs::read(&local) {
155+
if let Ok(p) = serde_json::from_slice::<Profile>(&bytes) {
156+
return Some((p, ProfileScope::Local));
157+
}
158+
}
159+
let shared = profile_path(project_root, ProfileScope::Shared, name);
160+
if let Ok(bytes) = fs::read(&shared) {
161+
if let Ok(p) = serde_json::from_slice::<Profile>(&bytes) {
162+
return Some((p, ProfileScope::Shared));
163+
}
164+
}
165+
None
166+
}
167+
168+
/// Lists all profiles found. If a name exists in both scopes, only the Local one is returned.
169+
pub fn list_profiles(project_root: &Path) -> Vec<ProfileMeta> {
170+
fn scan(dir: &Path, scope: ProfileScope, out: &mut Vec<(String, ProfileScope)>) {
171+
if let Ok(rd) = fs::read_dir(dir) {
172+
for ent in rd.flatten() {
173+
if let Some(ext) = ent.path().extension() {
174+
if ext == "json" {
175+
if let Some(os) = ent.path().file_stem() {
176+
let name = os.to_string_lossy().to_string();
177+
out.push((name, scope));
178+
}
179+
}
180+
}
181+
}
182+
}
183+
}
184+
185+
let mut raw: Vec<(String, ProfileScope)> = Vec::new();
186+
scan(&profiles_shared_dir(project_root), ProfileScope::Shared, &mut raw);
187+
scan(&profiles_local_dir(project_root), ProfileScope::Local, &mut raw);
188+
189+
// keep a single entry per name, preferring Local
190+
use std::collections::BTreeMap;
191+
let mut by_name: BTreeMap<String, ProfileScope> = BTreeMap::new();
192+
for (n, s) in raw {
193+
match by_name.get(&n) {
194+
None => {
195+
by_name.insert(n, s);
196+
}
197+
Some(prev) => {
198+
if *prev == ProfileScope::Shared && s == ProfileScope::Local {
199+
by_name.insert(n, s);
200+
}
201+
}
202+
}
203+
}
204+
205+
by_name
206+
.into_iter()
207+
.map(|(name, scope)| ProfileMeta { name, scope })
208+
.collect()
209+
}

src/main.rs

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@ use slint::ComponentHandle;
1414
use ui::{
1515
AppState, AppWindow, Row, SelectFromTextDialog, apply_selection_from_text, on_check_updates,
1616
on_copy_output, on_filter_changed, on_generate_output, on_select_folder, on_toggle_check,
17-
on_toggle_expand,
17+
on_toggle_expand, on_save_profile_as, on_select_profile, on_save_profile_current
1818
};
1919

2020
#[cfg(feature = "ui")]
2121
fn spawn_window(registry: Rc<RefCell<Vec<AppWindow>>>) -> anyhow::Result<()> {
2222
let app = AppWindow::new()?;
2323

24-
// Initialize UI properties
2524
app.set_app_version(env!("CARGO_PKG_VERSION").into());
2625
app.set_ext_filter("".into());
2726
app.set_exclude_dirs(".git, node_modules, target, _target, .elan, .lake, .idea, .vscode, _app, .svelte-kit, .sqlx, venv, .venv, __pycache__, LICENSES, fixtures".into());
@@ -37,18 +36,19 @@ fn spawn_window(registry: Rc<RefCell<Vec<AppWindow>>>) -> anyhow::Result<()> {
3736
app.set_copy_toast_text("".into());
3837
app.set_output_stats("0 chars • 0 tokens".into());
3938

40-
// Per-window state
39+
// Profiles UI defaults
40+
app.set_profiles(slint::ModelRc::new(slint::VecModel::from(Vec::<slint::SharedString>::new())));
41+
app.set_selected_profile_index(-1);
42+
4143
let state = Rc::new(RefCell::new(AppState {
4244
poll_interval_ms: 45_000,
4345
..Default::default()
4446
}));
4547

46-
// Periodic poll timer: capture a Weak to avoid moving `state` while borrowed
4748
{
4849
let app_weak = app.as_weak();
4950
let interval_ms = { state.borrow().poll_interval_ms };
5051
let state_weak = Rc::downgrade(&state);
51-
// borrow only long enough to call `start`, then drop before closure capture
5252
{
5353
let st = state.borrow();
5454
st.poll_timer.start(
@@ -64,7 +64,6 @@ fn spawn_window(registry: Rc<RefCell<Vec<AppWindow>>>) -> anyhow::Result<()> {
6464
}
6565
}
6666

67-
// UI callbacks (per window)
6867
{
6968
let app_weak = app.as_weak();
7069
let state = Rc::clone(&state);
@@ -120,7 +119,6 @@ fn spawn_window(registry: Rc<RefCell<Vec<AppWindow>>>) -> anyhow::Result<()> {
120119
});
121120
}
122121
{
123-
// "Select from Text…" dialog
124122
let app_weak = app.as_weak();
125123
let state = Rc::clone(&state);
126124

@@ -158,15 +156,42 @@ fn spawn_window(registry: Rc<RefCell<Vec<AppWindow>>>) -> anyhow::Result<()> {
158156
});
159157
}
160158

161-
// Multi-window: hook "New Window" button
159+
// Profiles: selection + save + save as
160+
{
161+
let app_weak = app.as_weak();
162+
let state = Rc::clone(&state);
163+
app.on_select_profile(move |idx| {
164+
if let Some(app) = app_weak.upgrade() {
165+
on_select_profile(&app, &state, idx);
166+
}
167+
});
168+
}
169+
{
170+
let app_weak = app.as_weak();
171+
let state = Rc::clone(&state);
172+
app.on_save_profile(move || {
173+
if let Some(app) = app_weak.upgrade() {
174+
on_save_profile_current(&app, &state);
175+
}
176+
});
177+
}
178+
{
179+
let app_weak = app.as_weak();
180+
let state = Rc::clone(&state);
181+
app.on_save_profile_as(move || {
182+
if let Some(app) = app_weak.upgrade() {
183+
on_save_profile_as(&app, &state);
184+
}
185+
});
186+
}
187+
162188
{
163189
let registry_clone = Rc::clone(&registry);
164190
app.on_new_window(move || {
165191
let _ = spawn_window(Rc::clone(&registry_clone));
166192
});
167193
}
168194

169-
// Show this window and keep the handle alive
170195
app.show()?;
171196
registry.borrow_mut().push(app);
172197

0 commit comments

Comments
 (0)