- 1
//! Which workspaces a person wants to *see*, as distinct from which ones - 2
//! exist. - 3
//! - 4
//! Adding a workspace was possible from every surface; removing one was - 5
//! possible from none. A folder opened once stayed in the list forever — - 6
//! including a folder opened by mistake, a folder since deleted, and the - 7
//! `/tmp` scratch directory somebody tried a prompt in. - 8
//! - 9
//! # Forgetting is not deleting - 10
//! - 11
//! This file records a *presentation* decision and nothing else. Forgetting - 12
//! a workspace removes it from the lists a surface shows. It does not touch: - 13
//! - 14
//! - the session ledgers under `<data_home>/sessions/<hash>/`, - 15
//! - memory, checkpoints, receipts, or commitments, - 16
//! - the project's own `.vak/config.toml`, secret scope, or trust decision. - 17
//! - 18
//! So re-adding it later restores everything, which is the property that - 19
//! makes forgetting safe to offer as a one-click action. Deleting a - 20
//! workspace's history is a different, far more consequential operation, and - 21
//! it deliberately does not live behind this door. - 22
//! - 23
//! # One store, every surface - 24
//! - 25
//! The desktop kept its own `recent_projects` in `desktop.json` while the - 26
//! server derived recents from the ledger directories, so the same folder - 27
//! could be listed in one surface and not the other, and "remove" in one - 28
//! would have meant nothing to the other. This is the single source of - 29
//! truth for all of them (AGENTS.md invariant 30). - 30
- 31
use std::collections::BTreeSet; - 32
use std::path::{Path, PathBuf}; - 33
- 34
use serde::{Deserialize, Serialize}; - 35
- 36
/// The on-disk shape. Sets, so repeated adds are idempotent and the file - 37
/// stays stable under diff. - 38
#[derive(Debug, Default, Clone, Serialize, Deserialize)] - 39
#[serde(default)] - 40
pub struct Workspaces { - 41
/// Bumped only for a breaking shape change. - 42
pub schema: u32, - 43
/// Workspaces a surface may offer, most-recently-opened first. Ordered, - 44
/// so this is a `Vec` rather than a set. - 45
pub known: Vec<PathBuf>, - 46
/// Workspaces the operator asked not to be shown. Kept rather than - 47
/// simply dropped from `known`, because a workspace is also discoverable - 48
/// from the ledger on disk — without a durable "no", it would reappear - 49
/// the moment anything rescanned. - 50
pub forgotten: BTreeSet<PathBuf>, - 51
} - 52
- 53
const SCHEMA: u32 = 1; - 54
const MAX_KNOWN: usize = 24; - 55
- 56
fn store_path() -> PathBuf { - 57
vak_config::paths::data_home().join("workspaces.json") - 58
} - 59
- 60
pub fn load() -> Workspaces { - 61
std::fs::read_to_string(store_path()) - 62
.ok() - 63
.and_then(|text| serde_json::from_str(&text).ok()) - 64
.unwrap_or_default() - 65
} - 66
- 67
fn save(value: &Workspaces) -> std::io::Result<()> { - 68
let path = store_path(); - 69
if let Some(parent) = path.parent() { - 70
std::fs::create_dir_all(parent)?; - 71
} - 72
let json = serde_json::to_string_pretty(value) - 73
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - 74
// Same-directory temp + rename: a torn write here would lose the list, - 75
// and the list is cheap to rebuild but annoying to lose. - 76
// - 77
// The temp name is UNIQUE PER WRITER. A fixed `.json.tmp` is a race - 78
// between any two writers — and there are always at least two here, the - 79
// desktop shell and the server, both live at once. Whichever renamed - 80
// first deleted the other's file out from under it, and the loser - 81
// failed with a bare ENOENT on a path it had just written. Found by - 82
// four tests failing in parallel and passing alone, which is what that - 83
// bug looks like from the outside. - 84
// Process id AND a per-call counter: two threads in one process share a - 85
// pid, so a pid-only name still raced (which is exactly how this was - 86
// found — four tests failing together and passing alone). - 87
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - 88
let unique = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - 89
let tmp = path.with_extension(format!("json.tmp.{}.{unique}", std::process::id())); - 90
std::fs::write(&tmp, json)?; - 91
std::fs::rename(&tmp, &path).inspect_err(|_| { - 92
let _ = std::fs::remove_file(&tmp); - 93
}) - 94
} - 95
- 96
/// Record `path` as opened: newest first, and no longer forgotten. - 97
/// - 98
/// The write is atomic — no reader ever sees a torn file — but a - 99
/// read-modify-write racing another writer can still lose an entry. That is - 100
/// deliberate: this is a list of folders to show, the loser's cost is one - 101
/// re-open or one re-forget, and a lock file guarding a recents list would - 102
/// be more machinery than the failure is worth. - 103
/// - 104
/// Re-opening is exactly how a forgotten workspace comes back — there is no - 105
/// separate "unforget" verb to discover, because the action a person takes - 106
/// is "open it again". - 107
pub fn remember(path: &Path) -> std::io::Result<()> { - 108
let path = canonical(path); - 109
let mut store = load(); - 110
store.schema = SCHEMA; - 111
store.forgotten.remove(&path); - 112
store.known.retain(|known| known != &path); - 113
store.known.insert(0, path); - 114
store.known.truncate(MAX_KNOWN); - 115
save(&store) - 116
} - 117
- 118
/// Stop showing `path`. Its sessions, memory, and settings are untouched. - 119
pub fn forget(path: &Path) -> std::io::Result<()> { - 120
let path = canonical(path); - 121
let mut store = load(); - 122
store.schema = SCHEMA; - 123
store.known.retain(|known| known != &path); - 124
store.forgotten.insert(path); - 125
save(&store) - 126
} - 127
- 128
/// Whether `path` has been explicitly forgotten. - 129
pub fn is_forgotten(path: &Path) -> bool { - 130
load().forgotten.contains(&canonical(path)) - 131
} - 132
- 133
/// The workspaces a surface should offer. - 134
/// - 135
/// `discovered` are paths found by other means (ledger directories, a - 136
/// shell's cwd); they are merged in, minus anything forgotten, so a surface - 137
/// never has to reimplement the filter and accidentally show one back. - 138
pub fn visible(discovered: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> { - 139
let store = load(); - 140
let mut out: Vec<PathBuf> = Vec::new(); - 141
for path in store.known.iter().cloned().chain(discovered) { - 142
let path = canonical(&path); - 143
if store.forgotten.contains(&path) || out.contains(&path) { - 144
continue; - 145
} - 146
// A folder that no longer exists is not worth offering; it is also - 147
// not worth a durable "forgotten" entry, since it may come back - 148
// (an unmounted volume, a worktree being rebuilt). - 149
if !path.is_dir() { - 150
continue; - 151
} - 152
out.push(path); - 153
} - 154
out - 155
} - 156
- 157
/// Resolve symlinks where possible so the same folder is one entry. - 158
/// - 159
/// `/tmp` and `/var` are symlinks on macOS, so the same directory reached - 160
/// two ways would otherwise be listed twice and "forget" would only hide - 161
/// one of them. - 162
fn canonical(path: &Path) -> PathBuf { - 163
path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) - 164
} - 165
- 166
#[cfg(test)] - 167
#[allow(clippy::unwrap_used, clippy::expect_used)] - 168
mod tests { - 169
use super::*; - 170
- 171
/// These tests share ONE store (the home override is process-global), so - 172
/// they are serialized. Running them concurrently is not a realistic - 173
/// scenario being covered — it is two tests fighting over one file. - 174
static STORE: std::sync::Mutex<()> = std::sync::Mutex::new(()); - 175
- 176
fn guard() -> std::sync::MutexGuard<'static, ()> { - 177
STORE.lock().unwrap_or_else(|e| e.into_inner()) - 178
} - 179
- 180
/// One shared, empty home for the whole test binary — the convention - 181
/// `isolate_home_for_tests` establishes, and the only one that works: - 182
/// the home override is PROCESS-global, so a per-test home is clobbered - 183
/// by whichever test runs next. These tests therefore share one store - 184
/// and each asserts only about its OWN uniquely-named workspace. - 185
fn workspace(name: &str) -> PathBuf { - 186
let home = vak_config::paths::isolate_home_for_tests(); - 187
let ws = home.join(name); - 188
std::fs::create_dir_all(&ws).unwrap(); - 189
canonical(&ws) - 190
} - 191
- 192
#[test] - 193
fn forgetting_hides_it_and_reopening_brings_it_back() { - 194
let _guard = guard(); - 195
let ws = workspace("forget-roundtrip"); - 196
- 197
remember(&ws).unwrap(); - 198
assert!(visible([]).contains(&ws)); - 199
- 200
forget(&ws).unwrap(); - 201
assert!(!visible([]).contains(&ws), "forgotten but still shown"); - 202
assert!(is_forgotten(&ws)); - 203
- 204
// Re-opening is the un-forget. No separate verb to discover. - 205
remember(&ws).unwrap(); - 206
assert!(visible([]).contains(&ws)); - 207
assert!(!is_forgotten(&ws)); - 208
} - 209
- 210
/// The whole safety argument: forgetting is a view decision, so nothing - 211
/// a later re-add would need may be destroyed by it. - 212
#[test] - 213
fn forgetting_touches_no_workspace_content() { - 214
let _guard = guard(); - 215
let ws = workspace("forget-keeps-content"); - 216
std::fs::create_dir_all(ws.join(".vak")).unwrap(); - 217
std::fs::write(ws.join(".vak/config.toml"), "model = \"kept\"\n").unwrap(); - 218
- 219
remember(&ws).unwrap(); - 220
forget(&ws).unwrap(); - 221
- 222
assert!(ws.is_dir(), "the workspace directory was removed"); - 223
assert_eq!( - 224
std::fs::read_to_string(ws.join(".vak/config.toml")).unwrap(), - 225
"model = \"kept\"\n", - 226
"forgetting a workspace must not touch its settings" - 227
); - 228
} - 229
- 230
/// A forgotten workspace that something else rediscovers (a ledger - 231
/// scan) must stay hidden — otherwise "remove" lasts until the next - 232
/// refresh, which is not removal. - 233
#[test] - 234
fn a_rediscovered_workspace_stays_forgotten() { - 235
let _guard = guard(); - 236
let ws = workspace("forget-rediscovered"); - 237
forget(&ws).unwrap(); - 238
assert!( - 239
!visible([ws.clone()]).contains(&ws), - 240
"rediscovery resurrected a forgotten workspace" - 241
); - 242
} - 243
- 244
/// A folder that no longer exists is not worth offering. - 245
#[test] - 246
fn a_missing_directory_is_not_offered() { - 247
let _guard = guard(); - 248
let ws = workspace("forget-missing"); - 249
remember(&ws).unwrap(); - 250
std::fs::remove_dir_all(&ws).unwrap(); - 251
assert!(!visible([]).contains(&ws)); - 252
} - 253
} - 254
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.