2026-09-22 21:04:34 -07:00
|
|
|
//! 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<u32>,
|
|
|
|
|
pub col: Option<u32>,
|
|
|
|
|
pub end_line: Option<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
|
|
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
|
|
|
pub enum ViewerTargetState {
|
|
|
|
|
Resolved { container_path: String },
|
|
|
|
|
Choose { candidates: Vec<String> },
|
|
|
|
|
NotFound { tried: Vec<String> },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<HashMap<String, ViewerTarget>>,
|
|
|
|
|
next: AtomicU64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ViewerRegistry {
|
|
|
|
|
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, ViewerTarget>> {
|
|
|
|
|
self.entries.lock().unwrap_or_else(|e| e.into_inner())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn reserve(&self, target: ViewerTarget) -> Result<String, String> {
|
|
|
|
|
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<ViewerTarget> {
|
|
|
|
|
self.lock().get(label).cloned()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result<ViewerTarget, String> {
|
|
|
|
|
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<String> {
|
|
|
|
|
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"]}"#);
|
|
|
|
|
}
|
|
|
|
|
}
|