Rust · 47664 bytes Raw Blame History
1 //! Workspace state management
2 //!
3 //! The Workspace is the defining unit of fackr. Every editing session
4 //! operates within a workspace context.
5
6 #![allow(dead_code)]
7
8 use anyhow::Result;
9 use serde::{Deserialize, Serialize};
10 use std::path::{Path, PathBuf};
11
12 use crate::buffer::Buffer;
13 use crate::editor::{Cursor, Cursors, History};
14 use crate::fuss::FussMode;
15 use crate::lsp::LspClient;
16 use crate::syntax::Highlighter;
17
18 // ============================================================================
19 // Serializable state structures for workspace persistence
20 // ============================================================================
21
22 /// Serializable workspace state for persistence
23 #[derive(Debug, Serialize, Deserialize)]
24 struct WorkspaceState {
25 active_tab: usize,
26 tabs: Vec<TabState>,
27 }
28
29 /// Serializable tab state
30 #[derive(Debug, Serialize, Deserialize)]
31 struct TabState {
32 /// Files open in this tab (by path)
33 files: Vec<FileState>,
34 /// Active pane index
35 active_pane: usize,
36 /// Pane configurations
37 panes: Vec<PaneState>,
38 }
39
40 /// Serializable file reference
41 #[derive(Debug, Serialize, Deserialize)]
42 struct FileState {
43 /// Path to file (None for unsaved)
44 path: Option<PathBuf>,
45 /// Whether file is outside workspace
46 is_orphan: bool,
47 }
48
49 /// Serializable pane state
50 #[derive(Debug, Serialize, Deserialize)]
51 struct PaneState {
52 /// Index into tab's files
53 buffer_idx: usize,
54 /// Primary cursor position
55 cursor_line: usize,
56 cursor_col: usize,
57 /// Viewport scroll position
58 viewport_line: usize,
59 viewport_col: usize,
60 /// Pane bounds (normalized 0.0-1.0)
61 bounds: BoundsState,
62 }
63
64 /// Serializable pane bounds
65 #[derive(Debug, Serialize, Deserialize)]
66 struct BoundsState {
67 x_start: f32,
68 y_start: f32,
69 x_end: f32,
70 y_end: f32,
71 }
72
73 /// Normalized pane bounds (0.0 to 1.0)
74 /// Converted to screen coordinates at render time
75 #[derive(Debug, Clone)]
76 pub struct PaneBounds {
77 pub x_start: f32,
78 pub y_start: f32,
79 pub x_end: f32,
80 pub y_end: f32,
81 }
82
83 impl Default for PaneBounds {
84 fn default() -> Self {
85 Self {
86 x_start: 0.0,
87 y_start: 0.0,
88 x_end: 1.0,
89 y_end: 1.0,
90 }
91 }
92 }
93
94 /// A buffer entry in a tab (file content with its undo history)
95 #[derive(Debug)]
96 pub struct BufferEntry {
97 /// File path (relative to workspace for workspace files, absolute for orphans)
98 /// None means unsaved new file
99 pub path: Option<PathBuf>,
100 /// The text buffer
101 pub buffer: Buffer,
102 /// Undo/redo history for this buffer
103 pub history: History,
104 /// Syntax highlighter for this buffer
105 pub highlighter: Highlighter,
106 /// File is outside workspace directory
107 pub is_orphan: bool,
108 /// Hash of buffer content at last save (None for new unsaved buffers)
109 saved_hash: Option<u64>,
110 /// Length of buffer at last save (sentinel for quick modified check)
111 saved_len: Option<usize>,
112 /// Whether current modifications have been backed up (reset on save)
113 pub backed_up: bool,
114 }
115
116 impl BufferEntry {
117 pub fn new() -> Self {
118 let mut buffer = Buffer::new();
119 let saved_hash = Some(buffer.content_hash()); // Empty buffer is "saved"
120 let saved_len = Some(buffer.len_chars());
121 Self {
122 path: None,
123 buffer,
124 history: History::new(),
125 highlighter: Highlighter::new(),
126 is_orphan: false,
127 saved_hash,
128 saved_len,
129 backed_up: false, // Will backup on first edit
130 }
131 }
132
133 /// Create a buffer from string content (for diff views, etc.)
134 /// The buffer is considered "saved" so it won't prompt for save on close
135 pub fn from_content(content: &str, display_name: Option<&str>) -> Self {
136 let mut buffer = Buffer::from_str(content);
137 let saved_hash = Some(buffer.content_hash());
138 let saved_len = Some(buffer.len_chars());
139
140 // Detect language from display name for syntax highlighting
141 let mut highlighter = Highlighter::new();
142 if let Some(name) = display_name {
143 highlighter.detect_language(name);
144 }
145
146 Self {
147 path: display_name.map(PathBuf::from),
148 buffer,
149 history: History::new(),
150 highlighter,
151 is_orphan: true, // Mark as orphan so path isn't prefixed with workspace root
152 saved_hash,
153 saved_len,
154 backed_up: true, // Content buffers (like diffs) don't need backup
155 }
156 }
157
158 /// Create an empty buffer for a new file that doesn't exist yet
159 pub fn new_file(path: &Path, workspace_root: &Path) -> Self {
160 let buffer = Buffer::new();
161 let is_orphan = !path.starts_with(workspace_root);
162
163 // Store relative path for workspace files, absolute for orphans
164 let stored_path = if is_orphan {
165 path.to_path_buf()
166 } else {
167 path.strip_prefix(workspace_root)
168 .unwrap_or(path)
169 .to_path_buf()
170 };
171
172 // Detect language for syntax highlighting
173 let mut highlighter = Highlighter::new();
174 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
175 highlighter.detect_language(filename);
176 }
177
178 Self {
179 path: Some(stored_path),
180 buffer,
181 history: History::new(),
182 highlighter,
183 is_orphan,
184 saved_hash: None, // Not saved yet - will prompt on close
185 saved_len: None,
186 backed_up: false, // Will backup on first edit
187 }
188 }
189
190 pub fn from_file(path: &Path, workspace_root: &Path) -> Result<Self> {
191 let mut buffer = Buffer::load(path)?;
192 let saved_hash = Some(buffer.content_hash()); // Hash at load time
193 let saved_len = Some(buffer.len_chars());
194 let is_orphan = !path.starts_with(workspace_root);
195
196 // Store relative path for workspace files, absolute for orphans
197 let stored_path = if is_orphan {
198 path.to_path_buf()
199 } else {
200 path.strip_prefix(workspace_root)
201 .unwrap_or(path)
202 .to_path_buf()
203 };
204
205 // Detect language for syntax highlighting
206 let mut highlighter = Highlighter::new();
207 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
208 highlighter.detect_language(filename);
209 }
210
211 Ok(Self {
212 path: Some(stored_path),
213 buffer,
214 history: History::new(),
215 highlighter,
216 is_orphan,
217 saved_hash,
218 saved_len,
219 backed_up: false, // Will backup on first edit
220 })
221 }
222
223 /// Get the display name for the tab bar
224 pub fn display_name(&self) -> String {
225 match &self.path {
226 Some(p) => p.file_name()
227 .and_then(|n| n.to_str())
228 .unwrap_or("[unknown]")
229 .to_string(),
230 None => "[new]".to_string(),
231 }
232 }
233
234 /// Check if buffer has been modified since last save
235 pub fn is_modified(&mut self) -> bool {
236 match (self.saved_hash, self.saved_len) {
237 (Some(hash), Some(len)) => {
238 // Quick check: if length differs, definitely modified
239 if self.buffer.len_chars() != len {
240 return true;
241 }
242 // Length matches - need to check content hash (uses cache)
243 self.buffer.content_hash() != hash
244 },
245 _ => true, // No saved state means never saved
246 }
247 }
248
249 /// Mark the buffer as saved (updates hash and length for change detection)
250 pub fn mark_saved(&mut self) {
251 self.saved_hash = Some(self.buffer.content_hash());
252 self.saved_len = Some(self.buffer.len_chars());
253 self.backed_up = false; // Reset - will backup on next edit
254 }
255 }
256
257 impl Default for BufferEntry {
258 fn default() -> Self {
259 Self::new()
260 }
261 }
262
263 /// A pane is a view into a buffer with its own cursor and viewport
264 #[derive(Debug)]
265 pub struct Pane {
266 /// Index into the tab's buffers vector
267 pub buffer_idx: usize,
268 /// Cursor positions within this pane
269 pub cursors: Cursors,
270 /// First visible line
271 pub viewport_line: usize,
272 /// First visible column (for horizontal scrolling)
273 pub viewport_col: usize,
274 /// Normalized bounds within the tab area
275 pub bounds: PaneBounds,
276 }
277
278 impl Default for Pane {
279 fn default() -> Self {
280 Self {
281 buffer_idx: 0,
282 cursors: Cursors::new(),
283 viewport_line: 0,
284 viewport_col: 0,
285 bounds: PaneBounds::default(),
286 }
287 }
288 }
289
290 impl Pane {
291 pub fn new() -> Self {
292 Self::default()
293 }
294
295 pub fn with_buffer_idx(buffer_idx: usize) -> Self {
296 Self {
297 buffer_idx,
298 ..Default::default()
299 }
300 }
301 }
302
303 /// A tab represents a view group with one or more panes viewing buffers
304 #[derive(Debug)]
305 pub struct Tab {
306 /// All buffers open in this tab (panes reference these by index)
307 pub buffers: Vec<BufferEntry>,
308 /// Views into buffers
309 pub panes: Vec<Pane>,
310 /// Which pane is active (index into panes)
311 pub active_pane: usize,
312 }
313
314 impl Tab {
315 /// Create a new empty tab
316 pub fn new() -> Self {
317 Self {
318 buffers: vec![BufferEntry::new()],
319 panes: vec![Pane::new()],
320 active_pane: 0,
321 }
322 }
323
324 /// Create a tab from a file
325 pub fn from_file(path: &Path, workspace_root: &Path) -> Result<Self> {
326 let buffer_entry = BufferEntry::from_file(path, workspace_root)?;
327 Ok(Self {
328 buffers: vec![buffer_entry],
329 panes: vec![Pane::new()],
330 active_pane: 0,
331 })
332 }
333
334 /// Create a tab for a new file that doesn't exist yet
335 pub fn new_file(path: &Path, workspace_root: &Path) -> Self {
336 let buffer_entry = BufferEntry::new_file(path, workspace_root);
337 Self {
338 buffers: vec![buffer_entry],
339 panes: vec![Pane::new()],
340 active_pane: 0,
341 }
342 }
343
344 /// Create a tab from string content (for diff views, etc.)
345 pub fn from_content(content: &str, display_name: &str) -> Self {
346 let buffer_entry = BufferEntry::from_content(content, Some(display_name));
347 Self {
348 buffers: vec![buffer_entry],
349 panes: vec![Pane::new()],
350 active_pane: 0,
351 }
352 }
353
354 /// Get the display name for the tab bar (uses primary buffer's name)
355 pub fn display_name(&self) -> String {
356 self.buffers.first()
357 .map(|b| b.display_name())
358 .unwrap_or_else(|| "[new]".to_string())
359 }
360
361 /// Check if any buffer has been modified
362 pub fn is_modified(&mut self) -> bool {
363 self.buffers.iter_mut().any(|b| b.is_modified())
364 }
365
366 /// Get the active pane
367 pub fn active_pane(&self) -> &Pane {
368 &self.panes[self.active_pane]
369 }
370
371 /// Get mutable reference to active pane
372 pub fn active_pane_mut(&mut self) -> &mut Pane {
373 &mut self.panes[self.active_pane]
374 }
375
376 /// Get the buffer for the active pane
377 pub fn active_buffer(&self) -> &BufferEntry {
378 let buffer_idx = self.panes[self.active_pane].buffer_idx;
379 &self.buffers[buffer_idx]
380 }
381
382 /// Get mutable reference to the buffer for the active pane
383 pub fn active_buffer_mut(&mut self) -> &mut BufferEntry {
384 let buffer_idx = self.panes[self.active_pane].buffer_idx;
385 &mut self.buffers[buffer_idx]
386 }
387
388 /// Get the buffer for a specific pane
389 pub fn buffer_for_pane(&self, pane_idx: usize) -> &BufferEntry {
390 let buffer_idx = self.panes[pane_idx].buffer_idx;
391 &self.buffers[buffer_idx]
392 }
393
394 /// Get mutable buffer for a specific pane
395 pub fn buffer_for_pane_mut(&mut self, pane_idx: usize) -> &mut BufferEntry {
396 let buffer_idx = self.panes[pane_idx].buffer_idx;
397 &mut self.buffers[buffer_idx]
398 }
399
400 /// Split the active pane vertically (new pane to the right, same buffer)
401 pub fn split_vertical(&mut self) {
402 let active = &self.panes[self.active_pane];
403 let buffer_idx = active.buffer_idx;
404 let old_bounds = active.bounds.clone();
405 let mid_x = (old_bounds.x_start + old_bounds.x_end) / 2.0;
406
407 // Shrink active pane
408 self.panes[self.active_pane].bounds.x_end = mid_x;
409
410 // Create new pane to the right
411 let mut new_pane = Pane::with_buffer_idx(buffer_idx);
412 new_pane.bounds = PaneBounds {
413 x_start: mid_x,
414 y_start: old_bounds.y_start,
415 x_end: old_bounds.x_end,
416 y_end: old_bounds.y_end,
417 };
418
419 self.panes.push(new_pane);
420 self.active_pane = self.panes.len() - 1;
421 }
422
423 /// Split the active pane horizontally (new pane below, same buffer)
424 pub fn split_horizontal(&mut self) {
425 let active = &self.panes[self.active_pane];
426 let buffer_idx = active.buffer_idx;
427 let old_bounds = active.bounds.clone();
428 let mid_y = (old_bounds.y_start + old_bounds.y_end) / 2.0;
429
430 // Shrink active pane
431 self.panes[self.active_pane].bounds.y_end = mid_y;
432
433 // Create new pane below
434 let mut new_pane = Pane::with_buffer_idx(buffer_idx);
435 new_pane.bounds = PaneBounds {
436 x_start: old_bounds.x_start,
437 y_start: mid_y,
438 x_end: old_bounds.x_end,
439 y_end: old_bounds.y_end,
440 };
441
442 self.panes.push(new_pane);
443 self.active_pane = self.panes.len() - 1;
444 }
445
446 /// Split vertical with a new file in the new pane
447 pub fn split_vertical_with_file(&mut self, path: &Path, workspace_root: &Path) -> Result<()> {
448 let buffer_entry = BufferEntry::from_file(path, workspace_root)?;
449 let new_buffer_idx = self.buffers.len();
450 self.buffers.push(buffer_entry);
451
452 let active = &self.panes[self.active_pane];
453 let old_bounds = active.bounds.clone();
454 let mid_x = (old_bounds.x_start + old_bounds.x_end) / 2.0;
455
456 // Shrink active pane
457 self.panes[self.active_pane].bounds.x_end = mid_x;
458
459 // Create new pane to the right with the new buffer
460 let mut new_pane = Pane::with_buffer_idx(new_buffer_idx);
461 new_pane.bounds = PaneBounds {
462 x_start: mid_x,
463 y_start: old_bounds.y_start,
464 x_end: old_bounds.x_end,
465 y_end: old_bounds.y_end,
466 };
467
468 self.panes.push(new_pane);
469 self.active_pane = self.panes.len() - 1;
470 Ok(())
471 }
472
473 /// Split horizontal with a new file in the new pane
474 pub fn split_horizontal_with_file(&mut self, path: &Path, workspace_root: &Path) -> Result<()> {
475 let buffer_entry = BufferEntry::from_file(path, workspace_root)?;
476 let new_buffer_idx = self.buffers.len();
477 self.buffers.push(buffer_entry);
478
479 let active = &self.panes[self.active_pane];
480 let old_bounds = active.bounds.clone();
481 let mid_y = (old_bounds.y_start + old_bounds.y_end) / 2.0;
482
483 // Shrink active pane
484 self.panes[self.active_pane].bounds.y_end = mid_y;
485
486 // Create new pane below with the new buffer
487 let mut new_pane = Pane::with_buffer_idx(new_buffer_idx);
488 new_pane.bounds = PaneBounds {
489 x_start: old_bounds.x_start,
490 y_start: mid_y,
491 x_end: old_bounds.x_end,
492 y_end: old_bounds.y_end,
493 };
494
495 self.panes.push(new_pane);
496 self.active_pane = self.panes.len() - 1;
497 Ok(())
498 }
499
500 /// Close the active pane
501 /// Returns true if the tab should be closed (no panes left)
502 pub fn close_active_pane(&mut self) -> bool {
503 if self.panes.len() <= 1 {
504 return true; // Last pane - tab should close
505 }
506
507 // Remove the pane
508 self.panes.remove(self.active_pane);
509 if self.active_pane >= self.panes.len() {
510 self.active_pane = self.panes.len() - 1;
511 }
512
513 // Recalculate bounds - for now just expand remaining panes equally
514 // This is a simplified approach; a proper tiling system would be more complex
515 self.recalculate_pane_bounds();
516 false
517 }
518
519 /// Recalculate pane bounds after closing a pane
520 fn recalculate_pane_bounds(&mut self) {
521 // Simple approach: split screen equally among remaining panes
522 let n = self.panes.len();
523 if n == 1 {
524 self.panes[0].bounds = PaneBounds::default();
525 } else {
526 // Arrange panes horizontally for now
527 for (i, pane) in self.panes.iter_mut().enumerate() {
528 let width = 1.0 / n as f32;
529 pane.bounds = PaneBounds {
530 x_start: i as f32 * width,
531 y_start: 0.0,
532 x_end: (i + 1) as f32 * width,
533 y_end: 1.0,
534 };
535 }
536 }
537 }
538
539 /// Navigate to the next pane
540 pub fn next_pane(&mut self) {
541 self.active_pane = (self.active_pane + 1) % self.panes.len();
542 }
543
544 /// Navigate to the previous pane
545 pub fn prev_pane(&mut self) {
546 if self.active_pane == 0 {
547 self.active_pane = self.panes.len() - 1;
548 } else {
549 self.active_pane -= 1;
550 }
551 }
552
553 /// Navigate to pane in direction (for vim-style navigation)
554 pub fn navigate_pane(&mut self, direction: PaneDirection) {
555 if self.panes.len() <= 1 {
556 return;
557 }
558
559 let current = &self.panes[self.active_pane];
560 let current_center_x = (current.bounds.x_start + current.bounds.x_end) / 2.0;
561 let current_center_y = (current.bounds.y_start + current.bounds.y_end) / 2.0;
562
563 let mut best_idx = None;
564 let mut best_score = f32::MAX;
565
566 for (i, pane) in self.panes.iter().enumerate() {
567 if i == self.active_pane {
568 continue;
569 }
570
571 let center_x = (pane.bounds.x_start + pane.bounds.x_end) / 2.0;
572 let center_y = (pane.bounds.y_start + pane.bounds.y_end) / 2.0;
573
574 let (is_valid, score) = match direction {
575 PaneDirection::Left => (center_x < current_center_x, current_center_x - center_x),
576 PaneDirection::Right => (center_x > current_center_x, center_x - current_center_x),
577 PaneDirection::Up => (center_y < current_center_y, current_center_y - center_y),
578 PaneDirection::Down => (center_y > current_center_y, center_y - current_center_y),
579 };
580
581 if is_valid && score < best_score {
582 best_score = score;
583 best_idx = Some(i);
584 }
585 }
586
587 if let Some(idx) = best_idx {
588 self.active_pane = idx;
589 }
590 }
591
592 /// Get number of panes
593 pub fn pane_count(&self) -> usize {
594 self.panes.len()
595 }
596
597 /// Find which pane contains a screen coordinate
598 /// Returns the pane index, or active_pane if no match found
599 pub fn pane_at_screen_position(&self, col: u16, row: u16, screen_cols: u16, screen_rows: u16, left_offset: u16, top_offset: u16) -> usize {
600 // Available space for panes (excluding fuss width and tab bar)
601 let available_width = screen_cols.saturating_sub(left_offset) as f32;
602 let available_height = screen_rows.saturating_sub(1 + top_offset) as f32; // -1 for status bar
603
604 // Adjust click coordinates for offsets
605 let adj_col = col.saturating_sub(left_offset) as f32;
606 let adj_row = row.saturating_sub(top_offset) as f32;
607
608 // Normalize coordinates to 0.0-1.0 range
609 let norm_x = adj_col / available_width;
610 let norm_y = adj_row / available_height;
611
612 // Find pane containing this normalized position
613 for (i, pane) in self.panes.iter().enumerate() {
614 if norm_x >= pane.bounds.x_start && norm_x < pane.bounds.x_end
615 && norm_y >= pane.bounds.y_start && norm_y < pane.bounds.y_end
616 {
617 return i;
618 }
619 }
620
621 // Default to active pane if no match
622 self.active_pane
623 }
624
625 /// Get the path of the primary buffer (for tab display and workspace tracking)
626 pub fn path(&self) -> Option<&PathBuf> {
627 self.buffers.first().and_then(|b| b.path.as_ref())
628 }
629
630 /// Check if the primary buffer is an orphan
631 pub fn is_orphan(&self) -> bool {
632 self.buffers.first().map(|b| b.is_orphan).unwrap_or(false)
633 }
634 }
635
636 /// Direction for pane navigation
637 #[derive(Debug, Clone, Copy)]
638 pub enum PaneDirection {
639 Left,
640 Right,
641 Up,
642 Down,
643 }
644
645 impl Default for Tab {
646 fn default() -> Self {
647 Self::new()
648 }
649 }
650
651 /// Workspace configuration
652 #[derive(Debug, Clone)]
653 pub struct WorkspaceConfig {
654 /// Tab width in spaces
655 pub tab_width: usize,
656 /// Use spaces instead of tabs
657 pub use_spaces: bool,
658 // Add more config options as needed
659 }
660
661 impl Default for WorkspaceConfig {
662 fn default() -> Self {
663 Self {
664 tab_width: 4,
665 use_spaces: true,
666 }
667 }
668 }
669
670 /// The Workspace - defining unit of fackr
671 ///
672 /// Every editing session operates within a workspace context.
673 /// A workspace is tied to a directory and persists state in .fackr/
674 pub struct Workspace {
675 /// Root directory of the workspace
676 pub root: PathBuf,
677 /// All open tabs
678 pub tabs: Vec<Tab>,
679 /// Currently active tab index
680 pub active_tab: usize,
681 /// Fuss mode (file tree) state
682 pub fuss: FussMode,
683 /// Workspace configuration
684 pub config: WorkspaceConfig,
685 /// LSP client for language server support
686 pub lsp: LspClient,
687 }
688
689 impl Workspace {
690 /// Create a new workspace for a directory
691 pub fn new(root: PathBuf) -> Self {
692 let mut fuss = FussMode::new();
693 fuss.init(&root);
694 let root_str = root.to_string_lossy().to_string();
695 let lsp = LspClient::new(&root_str);
696 Self {
697 root,
698 tabs: vec![Tab::new()],
699 active_tab: 0,
700 fuss,
701 config: WorkspaceConfig::default(),
702 lsp,
703 }
704 }
705
706 /// Initialize workspace directory structure (.fackr/)
707 pub fn init(&self) -> Result<()> {
708 let fackr_dir = self.root.join(".fackr");
709 if !fackr_dir.exists() {
710 std::fs::create_dir_all(&fackr_dir)?;
711 std::fs::create_dir_all(fackr_dir.join("backups"))?;
712 }
713 Ok(())
714 }
715
716 /// Check if a directory has an existing workspace
717 pub fn exists(dir: &Path) -> bool {
718 dir.join(".fackr").join("workspace.json").exists()
719 }
720
721 /// Detect workspace from a file path (searches parent directories)
722 pub fn detect_from_file(file_path: &Path) -> Option<PathBuf> {
723 let mut current = file_path.parent()?;
724 loop {
725 if Self::exists(current) {
726 return Some(current.to_path_buf());
727 }
728 match current.parent() {
729 Some(parent) => current = parent,
730 None => return None,
731 }
732 }
733 }
734
735 /// Open a workspace, creating .fackr/ if needed
736 pub fn open(root: PathBuf) -> Result<Self> {
737 let mut workspace = Self::new(root);
738 workspace.init()?;
739
740 // Try to load existing state
741 if let Err(_e) = workspace.load() {
742 // No existing state or failed to load - start fresh
743 // (workspace already has default empty tab)
744 }
745
746 Ok(workspace)
747 }
748
749 /// Open a workspace with a specific file
750 pub fn open_with_file(file_path: &Path) -> Result<Self> {
751 // Canonicalize the path to handle relative paths
752 let abs_path = file_path.canonicalize()
753 .unwrap_or_else(|_| file_path.to_path_buf());
754
755 // Determine workspace root
756 let root = Self::detect_from_file(&abs_path)
757 .or_else(|| abs_path.parent().map(|p| p.to_path_buf()))
758 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
759
760 let mut workspace = Self::open(root)?;
761
762 // Open the file in a tab (or create new file if it doesn't exist)
763 if abs_path.exists() {
764 workspace.open_file(&abs_path)?;
765 } else if abs_path.extension().is_some() || abs_path.file_name().is_some() {
766 // Path looks like a file (has extension or filename) - create new file buffer
767 workspace.open_new_file(&abs_path)?;
768 }
769
770 Ok(workspace)
771 }
772
773 /// Load workspace state from .fackr/workspace.json
774 pub fn load(&mut self) -> Result<()> {
775 let state_path = self.root.join(".fackr").join("workspace.json");
776 if !state_path.exists() {
777 return Ok(());
778 }
779
780 // Read and parse JSON
781 let json = std::fs::read_to_string(&state_path)?;
782 let state: WorkspaceState = match serde_json::from_str(&json) {
783 Ok(s) => s,
784 Err(e) => {
785 // If JSON is corrupted, log and continue with empty workspace
786 eprintln!("Warning: Failed to parse workspace.json: {}", e);
787 return Ok(());
788 }
789 };
790
791 // Restore tabs from state
792 let mut restored_tabs = Vec::new();
793 for tab_state in state.tabs {
794 // Try to open each file in the tab
795 let mut buffers = Vec::new();
796 let mut valid_buffer_map: Vec<Option<usize>> = Vec::new(); // Maps old index to new index
797
798 for file_state in &tab_state.files {
799 if let Some(ref path) = file_state.path {
800 // Resolve path (relative or absolute based on orphan status)
801 let full_path = if file_state.is_orphan {
802 path.clone()
803 } else {
804 self.root.join(path)
805 };
806
807 // Only restore if file still exists
808 if full_path.exists() {
809 match BufferEntry::from_file(&full_path, &self.root) {
810 Ok(entry) => {
811 valid_buffer_map.push(Some(buffers.len()));
812 buffers.push(entry);
813 }
814 Err(_) => {
815 valid_buffer_map.push(None);
816 }
817 }
818 } else {
819 valid_buffer_map.push(None);
820 }
821 } else {
822 // Unsaved file - skip it (can't restore without content)
823 valid_buffer_map.push(None);
824 }
825 }
826
827 // Skip tab if no files could be restored
828 if buffers.is_empty() {
829 continue;
830 }
831
832 // Restore panes, mapping buffer indices
833 let mut panes = Vec::new();
834 for pane_state in &tab_state.panes {
835 // Check if this pane's buffer was successfully loaded
836 if let Some(Some(new_idx)) = valid_buffer_map.get(pane_state.buffer_idx) {
837 let mut pane = Pane::with_buffer_idx(*new_idx);
838
839 // Restore cursor position (clamped to buffer bounds)
840 let buffer = &buffers[*new_idx].buffer;
841 let line = pane_state.cursor_line.min(buffer.line_count().saturating_sub(1));
842 let col = if line < buffer.line_count() {
843 pane_state.cursor_col.min(buffer.line_len(line))
844 } else {
845 0
846 };
847 pane.cursors = Cursors::from_cursor(Cursor {
848 line,
849 col,
850 desired_col: col,
851 anchor_line: line,
852 anchor_col: col,
853 selecting: false,
854 });
855
856 // Restore viewport
857 pane.viewport_line = pane_state.viewport_line.min(buffer.line_count().saturating_sub(1));
858 pane.viewport_col = pane_state.viewport_col;
859
860 // Restore bounds
861 pane.bounds = PaneBounds {
862 x_start: pane_state.bounds.x_start,
863 y_start: pane_state.bounds.y_start,
864 x_end: pane_state.bounds.x_end,
865 y_end: pane_state.bounds.y_end,
866 };
867
868 panes.push(pane);
869 }
870 }
871
872 // Ensure at least one pane exists
873 if panes.is_empty() {
874 panes.push(Pane::default());
875 }
876
877 // Clamp active_pane to valid range
878 let active_pane = tab_state.active_pane.min(panes.len().saturating_sub(1));
879
880 restored_tabs.push(Tab {
881 buffers,
882 panes,
883 active_pane,
884 });
885 }
886
887 // Only replace tabs if we successfully restored at least one
888 if !restored_tabs.is_empty() {
889 self.tabs = restored_tabs;
890 self.active_tab = state.active_tab.min(self.tabs.len().saturating_sub(1));
891 }
892
893 Ok(())
894 }
895
896 /// Save workspace state to .fackr/workspace.json
897 pub fn save(&self) -> Result<()> {
898 self.init()?; // Ensure .fackr/ exists
899
900 let state_path = self.root.join(".fackr").join("workspace.json");
901
902 // Build serializable state
903 let mut tabs = Vec::new();
904 for tab in &self.tabs {
905 // Collect file states
906 let files: Vec<FileState> = tab.buffers.iter().map(|b| {
907 FileState {
908 path: b.path.clone(),
909 is_orphan: b.is_orphan,
910 }
911 }).collect();
912
913 // Only save tabs that have at least one saved file
914 if files.iter().all(|f| f.path.is_none()) {
915 continue;
916 }
917
918 // Collect pane states
919 let panes: Vec<PaneState> = tab.panes.iter().map(|p| {
920 let cursor = p.cursors.primary();
921 PaneState {
922 buffer_idx: p.buffer_idx,
923 cursor_line: cursor.line,
924 cursor_col: cursor.col,
925 viewport_line: p.viewport_line,
926 viewport_col: p.viewport_col,
927 bounds: BoundsState {
928 x_start: p.bounds.x_start,
929 y_start: p.bounds.y_start,
930 x_end: p.bounds.x_end,
931 y_end: p.bounds.y_end,
932 },
933 }
934 }).collect();
935
936 tabs.push(TabState {
937 files,
938 active_pane: tab.active_pane,
939 panes,
940 });
941 }
942
943 // Don't save if there's nothing meaningful to save
944 if tabs.is_empty() {
945 // Remove old state file if it exists
946 if state_path.exists() {
947 let _ = std::fs::remove_file(&state_path);
948 }
949 return Ok(());
950 }
951
952 let state = WorkspaceState {
953 active_tab: self.active_tab.min(tabs.len().saturating_sub(1)),
954 tabs,
955 };
956
957 // Serialize and write
958 let json = serde_json::to_string_pretty(&state)?;
959 std::fs::write(&state_path, json)?;
960
961 Ok(())
962 }
963
964 /// Get the active tab
965 pub fn active_tab(&self) -> &Tab {
966 &self.tabs[self.active_tab]
967 }
968
969 /// Get mutable reference to active tab
970 pub fn active_tab_mut(&mut self) -> &mut Tab {
971 &mut self.tabs[self.active_tab]
972 }
973
974 /// Check if a tab is an empty default tab (no path, not modified, empty content)
975 fn is_empty_default_tab(tab: &mut Tab) -> bool {
976 if tab.buffers.len() != 1 || tab.panes.len() != 1 {
977 return false;
978 }
979 let buf = &mut tab.buffers[0];
980 buf.path.is_none() && !buf.is_modified() && buf.buffer.len_chars() == 0
981 }
982
983 /// Open a file in a new tab
984 pub fn open_file(&mut self, path: &Path) -> Result<()> {
985 // Check if file is already open in any tab's primary buffer
986 let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
987 for (i, tab) in self.tabs.iter().enumerate() {
988 if let Some(tab_path) = tab.path() {
989 let full_path = if tab.is_orphan() {
990 tab_path.clone()
991 } else {
992 self.root.join(tab_path)
993 };
994 if full_path.canonicalize().ok() == Some(abs_path.clone()) {
995 // File already open - switch to it
996 self.active_tab = i;
997 return Ok(());
998 }
999 }
1000 }
1001
1002 // Open new tab
1003 let tab = Tab::from_file(path, &self.root)?;
1004
1005 // Notify LSP server of newly opened file
1006 if let Some(file_path) = tab.path() {
1007 let full_path = if tab.is_orphan() {
1008 file_path.clone()
1009 } else {
1010 self.root.join(file_path)
1011 };
1012 let path_str = full_path.to_string_lossy();
1013 let content = tab.buffers[0].buffer.contents();
1014 let _ = self.lsp.open_document(&path_str, &content);
1015 }
1016
1017 // If we have exactly one empty default tab, replace it instead of adding
1018 if self.tabs.len() == 1 && Self::is_empty_default_tab(&mut self.tabs[0]) {
1019 self.tabs[0] = tab;
1020 self.active_tab = 0;
1021 } else {
1022 self.tabs.push(tab);
1023 self.active_tab = self.tabs.len() - 1;
1024 }
1025 Ok(())
1026 }
1027
1028 /// Open a new file (doesn't exist yet) in a new tab
1029 pub fn open_new_file(&mut self, path: &Path) -> Result<()> {
1030 let tab = Tab::new_file(path, &self.root);
1031
1032 // If we have exactly one empty default tab, replace it instead of adding
1033 if self.tabs.len() == 1 && Self::is_empty_default_tab(&mut self.tabs[0]) {
1034 self.tabs[0] = tab;
1035 self.active_tab = 0;
1036 } else {
1037 self.tabs.push(tab);
1038 self.active_tab = self.tabs.len() - 1;
1039 }
1040 Ok(())
1041 }
1042
1043 /// Open a file in a vertical split pane in the current tab
1044 pub fn open_file_in_vsplit(&mut self, path: &Path) -> Result<()> {
1045 self.tabs[self.active_tab].split_vertical_with_file(path, &self.root)
1046 }
1047
1048 /// Open a file in a horizontal split pane in the current tab
1049 pub fn open_file_in_hsplit(&mut self, path: &Path) -> Result<()> {
1050 self.tabs[self.active_tab].split_horizontal_with_file(path, &self.root)
1051 }
1052
1053 /// Create a new empty tab
1054 pub fn new_tab(&mut self) {
1055 self.tabs.push(Tab::new());
1056 self.active_tab = self.tabs.len() - 1;
1057 }
1058
1059 /// Open a content tab (for diff views, etc.)
1060 pub fn open_content_tab(&mut self, content: &str, display_name: &str) {
1061 let tab = Tab::from_content(content, display_name);
1062 self.tabs.push(tab);
1063 self.active_tab = self.tabs.len() - 1;
1064 }
1065
1066 /// Close the active tab
1067 /// Returns true if the workspace should close (no tabs left)
1068 pub fn close_active_tab(&mut self) -> bool {
1069 if self.tabs.len() <= 1 {
1070 return true; // Last tab - workspace should close
1071 }
1072
1073 self.tabs.remove(self.active_tab);
1074 if self.active_tab >= self.tabs.len() {
1075 self.active_tab = self.tabs.len() - 1;
1076 }
1077 false
1078 }
1079
1080 /// Switch to tab by index (0-based)
1081 pub fn switch_to_tab(&mut self, index: usize) {
1082 if index < self.tabs.len() {
1083 self.active_tab = index;
1084 }
1085 }
1086
1087 /// Switch to next tab (wraps around)
1088 pub fn next_tab(&mut self) {
1089 self.active_tab = (self.active_tab + 1) % self.tabs.len();
1090 }
1091
1092 /// Switch to previous tab (wraps around)
1093 pub fn prev_tab(&mut self) {
1094 if self.active_tab == 0 {
1095 self.active_tab = self.tabs.len() - 1;
1096 } else {
1097 self.active_tab -= 1;
1098 }
1099 }
1100
1101 /// Get number of tabs
1102 pub fn tab_count(&self) -> usize {
1103 self.tabs.len()
1104 }
1105
1106 // === Backup functionality ===
1107
1108 /// Get the backups directory path
1109 fn backups_dir(&self) -> PathBuf {
1110 self.root.join(".fackr").join("backups")
1111 }
1112
1113 /// Generate a backup filename for a buffer path
1114 /// Uses a hash of the path to create a unique but deterministic name
1115 fn backup_filename(&self, path: &Path) -> String {
1116 use std::collections::hash_map::DefaultHasher;
1117 use std::hash::{Hash, Hasher};
1118
1119 let mut hasher = DefaultHasher::new();
1120 path.hash(&mut hasher);
1121 format!("{:016x}.bak", hasher.finish())
1122 }
1123
1124 /// Write a backup for a modified buffer
1125 pub fn write_backup(&self, path: &Path, content: &str) -> Result<()> {
1126 let backups_dir = self.backups_dir();
1127 std::fs::create_dir_all(&backups_dir)?;
1128
1129 let backup_path = backups_dir.join(self.backup_filename(path));
1130
1131 // Store as simple format: first line is original path, rest is content
1132 let backup_content = format!("{}\n{}", path.display(), content);
1133 std::fs::write(&backup_path, backup_content)?;
1134
1135 Ok(())
1136 }
1137
1138 /// Delete backup for a buffer (called after successful save)
1139 pub fn delete_backup(&self, path: &Path) -> Result<()> {
1140 let backup_path = self.backups_dir().join(self.backup_filename(path));
1141 if backup_path.exists() {
1142 std::fs::remove_file(backup_path)?;
1143 }
1144 Ok(())
1145 }
1146
1147 /// Delete all backups (called on discard)
1148 pub fn delete_all_backups(&self) -> Result<()> {
1149 let backups_dir = self.backups_dir();
1150 if backups_dir.exists() {
1151 for entry in std::fs::read_dir(&backups_dir)? {
1152 let entry = entry?;
1153 if entry.path().extension().map_or(false, |e| e == "bak") {
1154 std::fs::remove_file(entry.path())?;
1155 }
1156 }
1157 }
1158 Ok(())
1159 }
1160
1161 /// Check if there are any backups to restore
1162 pub fn has_backups(&self) -> bool {
1163 let backups_dir = self.backups_dir();
1164 if !backups_dir.exists() {
1165 return false;
1166 }
1167 if let Ok(entries) = std::fs::read_dir(&backups_dir) {
1168 for entry in entries.flatten() {
1169 if entry.path().extension().map_or(false, |e| e == "bak") {
1170 return true;
1171 }
1172 }
1173 }
1174 false
1175 }
1176
1177 /// Get list of backup info (original path, backup path)
1178 pub fn list_backups(&self) -> Vec<(PathBuf, PathBuf)> {
1179 let mut backups = Vec::new();
1180 let backups_dir = self.backups_dir();
1181
1182 if !backups_dir.exists() {
1183 return backups;
1184 }
1185
1186 if let Ok(entries) = std::fs::read_dir(&backups_dir) {
1187 for entry in entries.flatten() {
1188 let backup_path = entry.path();
1189 if backup_path.extension().map_or(false, |e| e == "bak") {
1190 // Read first line to get original path
1191 if let Ok(content) = std::fs::read_to_string(&backup_path) {
1192 if let Some(first_line) = content.lines().next() {
1193 backups.push((PathBuf::from(first_line), backup_path));
1194 }
1195 }
1196 }
1197 }
1198 }
1199
1200 backups
1201 }
1202
1203 /// Restore a backup into its buffer
1204 /// Returns the original path and content
1205 pub fn read_backup(&self, backup_path: &Path) -> Result<(PathBuf, String)> {
1206 let content = std::fs::read_to_string(backup_path)?;
1207 let mut lines = content.lines();
1208
1209 let original_path = lines.next()
1210 .ok_or_else(|| anyhow::anyhow!("Invalid backup file: missing path"))?;
1211
1212 let content: String = lines.collect::<Vec<_>>().join("\n");
1213
1214 Ok((PathBuf::from(original_path), content))
1215 }
1216
1217 /// Check if any buffer in the workspace has unsaved changes
1218 pub fn has_unsaved_changes(&mut self) -> bool {
1219 for tab in &mut self.tabs {
1220 for buffer_entry in &mut tab.buffers {
1221 if buffer_entry.is_modified() {
1222 return true;
1223 }
1224 }
1225 }
1226 false
1227 }
1228
1229 /// Get list of modified buffer paths
1230 pub fn modified_buffers(&mut self) -> Vec<PathBuf> {
1231 let mut modified = Vec::new();
1232 for tab in &mut self.tabs {
1233 for buffer_entry in &mut tab.buffers {
1234 if buffer_entry.is_modified() {
1235 if let Some(path) = &buffer_entry.path {
1236 // Convert relative path to absolute
1237 let full_path = if buffer_entry.is_orphan {
1238 path.clone()
1239 } else {
1240 self.root.join(path)
1241 };
1242 modified.push(full_path);
1243 }
1244 }
1245 }
1246 }
1247 modified
1248 }
1249
1250 /// Save all modified buffers
1251 pub fn save_all(&mut self) -> Result<()> {
1252 // Collect paths to save first to avoid borrow issues
1253 let mut to_save: Vec<(usize, usize, PathBuf)> = Vec::new();
1254
1255 for (tab_idx, tab) in self.tabs.iter_mut().enumerate() {
1256 for (buf_idx, buffer_entry) in tab.buffers.iter_mut().enumerate() {
1257 if buffer_entry.is_modified() {
1258 if let Some(path) = &buffer_entry.path {
1259 let full_path = if buffer_entry.is_orphan {
1260 path.clone()
1261 } else {
1262 self.root.join(path)
1263 };
1264 to_save.push((tab_idx, buf_idx, full_path));
1265 }
1266 }
1267 }
1268 }
1269
1270 // Now save each buffer
1271 for (tab_idx, buf_idx, full_path) in to_save {
1272 self.tabs[tab_idx].buffers[buf_idx].buffer.save(&full_path)?;
1273 self.tabs[tab_idx].buffers[buf_idx].mark_saved();
1274 // Delete backup after successful save
1275 let _ = self.delete_backup(&full_path);
1276 }
1277
1278 Ok(())
1279 }
1280
1281 /// Write backups for all modified buffers
1282 pub fn backup_all_modified(&mut self) -> Result<()> {
1283 // Collect backup info first to avoid borrow issues
1284 let mut to_backup: Vec<(PathBuf, String)> = Vec::new();
1285
1286 for tab in &mut self.tabs {
1287 for buffer_entry in &mut tab.buffers {
1288 if buffer_entry.is_modified() {
1289 if let Some(path) = &buffer_entry.path {
1290 let full_path = if buffer_entry.is_orphan {
1291 path.clone()
1292 } else {
1293 self.root.join(path)
1294 };
1295 let content = buffer_entry.buffer.contents();
1296 to_backup.push((full_path, content));
1297 }
1298 }
1299 }
1300 }
1301
1302 for (full_path, content) in to_backup {
1303 self.write_backup(&full_path, &content)?;
1304 }
1305 Ok(())
1306 }
1307
1308 /// Get the workspace directory name (repo name)
1309 pub fn repo_name(&self) -> String {
1310 self.root
1311 .file_name()
1312 .and_then(|n| n.to_str())
1313 .unwrap_or("workspace")
1314 .to_string()
1315 }
1316
1317 /// Get the current git branch name, if in a git repo
1318 pub fn git_branch(&self) -> Option<String> {
1319 use std::process::Command;
1320
1321 let output = Command::new("git")
1322 .arg("-C")
1323 .arg(&self.root)
1324 .arg("branch")
1325 .arg("--show-current")
1326 .output()
1327 .ok()?;
1328
1329 if output.status.success() {
1330 let branch = String::from_utf8_lossy(&output.stdout)
1331 .trim()
1332 .to_string();
1333 if branch.is_empty() {
1334 // Detached HEAD - try to get short SHA
1335 let sha_output = Command::new("git")
1336 .arg("-C")
1337 .arg(&self.root)
1338 .arg("rev-parse")
1339 .arg("--short")
1340 .arg("HEAD")
1341 .output()
1342 .ok()?;
1343 if sha_output.status.success() {
1344 let sha = String::from_utf8_lossy(&sha_output.stdout)
1345 .trim()
1346 .to_string();
1347 return Some(format!("({})", sha));
1348 }
1349 None
1350 } else {
1351 Some(branch)
1352 }
1353 } else {
1354 None // Not a git repo
1355 }
1356 }
1357
1358 /// Check if this workspace is a git repository
1359 pub fn is_git_repo(&self) -> bool {
1360 self.root.join(".git").exists()
1361 }
1362
1363 /// Find a tab by file path, returns tab index if found
1364 pub fn find_tab_by_path(&self, path: &std::path::Path) -> Option<usize> {
1365 for (tab_idx, tab) in self.tabs.iter().enumerate() {
1366 for buffer_entry in &tab.buffers {
1367 if let Some(buf_path) = &buffer_entry.path {
1368 // Get full path for comparison
1369 let full_path = if buffer_entry.is_orphan {
1370 buf_path.clone()
1371 } else {
1372 self.root.join(buf_path)
1373 };
1374 if full_path == path {
1375 return Some(tab_idx);
1376 }
1377 }
1378 }
1379 }
1380 None
1381 }
1382
1383 /// Apply a text edit to a buffer in a specific tab
1384 pub fn apply_text_edit(&mut self, tab_idx: usize, edit: &crate::lsp::TextEdit) {
1385 if tab_idx >= self.tabs.len() {
1386 return;
1387 }
1388
1389 let tab = &mut self.tabs[tab_idx];
1390 if tab.buffers.is_empty() {
1391 return;
1392 }
1393
1394 let buffer = &mut tab.buffers[0].buffer;
1395
1396 // Convert LSP range to buffer char indices
1397 let start_line = edit.range.start.line as usize;
1398 let start_col = edit.range.start.character as usize;
1399 let end_line = edit.range.end.line as usize;
1400 let end_col = edit.range.end.character as usize;
1401
1402 let start_char = buffer.line_col_to_char(start_line, start_col);
1403 let end_char = buffer.line_col_to_char(end_line, end_col);
1404
1405 // Delete the old text first (if range is non-empty)
1406 if start_char < end_char {
1407 buffer.delete(start_char, end_char);
1408 }
1409
1410 // Insert the new text at start position
1411 if !edit.new_text.is_empty() {
1412 buffer.insert(start_char, &edit.new_text);
1413 }
1414 // Buffer automatically tracks modifications via content hash
1415 }
1416
1417 /// Find which pane in the active tab contains a screen coordinate
1418 /// Returns the pane index
1419 pub fn pane_at_position(&self, col: u16, row: u16, screen_cols: u16, screen_rows: u16) -> usize {
1420 // Calculate offsets for fuss mode and tab bar
1421 let fuss_width = if self.fuss.active {
1422 self.fuss.width(screen_cols)
1423 } else {
1424 0
1425 };
1426 let top_offset = if self.tabs.len() > 1 { 1u16 } else { 0 };
1427
1428 self.tabs[self.active_tab].pane_at_screen_position(
1429 col, row, screen_cols, screen_rows, fuss_width, top_offset
1430 )
1431 }
1432 }
1433