From d32dc9b446a7de7d9f3e59da53b772f1909c0429 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 22 Sep 2026 21:04:34 -0700 Subject: [PATCH] feat(viewer): window registry with cap, dedupe and sequential labels Task 2 of the terminal file viewer plan: ViewerRegistry tracks which file-viewer- window is looking at which container path. reserve() takes the cap check and label allocation atomically under one lock so two concurrent open requests cannot both slip past the 20-window cap; find_open() only matches windows in the Resolved state, so a window still choosing a candidate or reporting not-found is never treated as "open on" a path. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/src-tauri/src/file_viewer/registry.rs | 163 +++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/file_viewer/registry.rs b/app/src-tauri/src/file_viewer/registry.rs index 9482e55..0f86e13 100644 --- a/app/src-tauri/src/file_viewer/registry.rs +++ b/app/src-tauri/src/file_viewer/registry.rs @@ -1 +1,162 @@ -//! Filled in by Task N. +//! Which viewer window is looking at what. +//! +//! Managed with `app.manage(ViewerRegistry::default())` rather than as a field on +//! `AppState`, like the browser view keeps its own state. A label is reserved *before* +//! the window is built so two concurrent clicks cannot both pass the cap check. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +use super::{MAX_VIEWER_WINDOWS, VIEWER_LABEL_PREFIX}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct Location { + pub line: Option, + pub col: Option, + pub end_line: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ViewerTargetState { + Resolved { container_path: String }, + Choose { candidates: Vec }, + NotFound { tried: Vec }, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct ViewerTarget { + pub project_id: String, + pub project_name: String, + pub raw_path: String, + pub state: ViewerTargetState, + pub initial: Location, +} + +#[derive(Default)] +pub struct ViewerRegistry { + entries: Mutex>, + next: AtomicU64, +} + +impl ViewerRegistry { + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.entries.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub fn reserve(&self, target: ViewerTarget) -> Result { + let mut entries = self.lock(); + if entries.len() >= MAX_VIEWER_WINDOWS { + return Err(format!( + "{} file windows are already open — close one before opening another.", + MAX_VIEWER_WINDOWS + )); + } + let n = self.next.fetch_add(1, Ordering::SeqCst) + 1; + let label = format!("{}{}", VIEWER_LABEL_PREFIX, n); + entries.insert(label.clone(), target); + Ok(label) + } + + pub fn get(&self, label: &str) -> Option { + self.lock().get(label).cloned() + } + + pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result { + let mut entries = self.lock(); + let entry = entries + .get_mut(label) + .ok_or_else(|| "This file window is no longer registered.".to_string())?; + entry.state = state; + Ok(entry.clone()) + } + + pub fn remove(&self, label: &str) { + self.lock().remove(label); + } + + pub fn find_open(&self, project_id: &str, container_path: &str) -> Option { + self.lock() + .iter() + .find(|(_, t)| { + t.project_id == project_id + && matches!(&t.state, ViewerTargetState::Resolved { container_path: p } if p == container_path) + }) + .map(|(label, _)| label.clone()) + } + + pub fn len(&self) -> usize { + self.lock().len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn target(project: &str, path: &str) -> ViewerTarget { + ViewerTarget { + project_id: project.into(), + project_name: "Demo".into(), + raw_path: path.into(), + state: ViewerTargetState::Resolved { container_path: path.into() }, + initial: Location { line: Some(3), col: None, end_line: None }, + } + } + + #[test] + fn labels_are_sequential_and_never_reused() { + let r = ViewerRegistry::default(); + let a = r.reserve(target("p", "/workspace/a")).unwrap(); + let b = r.reserve(target("p", "/workspace/b")).unwrap(); + assert_eq!(a, "file-viewer-1"); + assert_eq!(b, "file-viewer-2"); + r.remove(&a); + let c = r.reserve(target("p", "/workspace/c")).unwrap(); + assert_eq!(c, "file-viewer-3"); + assert_eq!(r.len(), 2); + } + + #[test] + fn the_cap_refuses_the_twenty_first_window() { + let r = ViewerRegistry::default(); + for i in 0..MAX_VIEWER_WINDOWS { + r.reserve(target("p", &format!("/workspace/{}", i))).unwrap(); + } + let err = r.reserve(target("p", "/workspace/one-more")).unwrap_err(); + assert!(err.contains("20"), "{}", err); + assert_eq!(r.len(), MAX_VIEWER_WINDOWS); + } + + #[test] + fn an_open_resolved_file_is_found_by_project_and_path() { + let r = ViewerRegistry::default(); + let label = r.reserve(target("p", "/workspace/a")).unwrap(); + assert_eq!(r.find_open("p", "/workspace/a"), Some(label.clone())); + assert_eq!(r.find_open("other", "/workspace/a"), None); + // A window still choosing is not "open on" any path. + r.set_state(&label, ViewerTargetState::Choose { candidates: vec!["/workspace/a".into()] }).unwrap(); + assert_eq!(r.find_open("p", "/workspace/a"), None); + r.remove(&label); + assert_eq!(r.get(&label), None); + } + + #[test] + fn set_state_on_an_unknown_label_is_an_error() { + let r = ViewerRegistry::default(); + assert!(r.set_state("file-viewer-9", ViewerTargetState::NotFound { tried: vec![] }).is_err()); + } + + #[test] + fn target_state_serialises_with_a_kind_tag() { + let s = serde_json::to_string(&ViewerTargetState::NotFound { tried: vec!["/x".into()] }).unwrap(); + assert_eq!(s, r#"{"kind":"not_found","tried":["/x"]}"#); + } +}