Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 193 additions & 29 deletions crates/okena-app/src/views/panels/project_column.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,53 @@ fn project_header_display_name(project: &ProjectData) -> String {
project.name.clone()
}

/// What the project column paints in its main content area.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ColumnContent {
/// Worktree teardown is underway and its terminals are already gone.
Closing,
/// Normal case: render the pane tree.
Layout,
/// Worktree checkout is still being created.
Creating,
/// Bookmark project with no terminal attached.
Empty,
}

/// Pick the content branch for a project column.
///
/// `Closing` wins over `Layout` because a closing worktree keeps `layout: Some`
/// — `prepare_background_worktree_removal` nulls every leaf's `terminal_id` but
/// leaves the tree shape standing so `ensure_terminal` can't resurrect a PTY
/// inside the doomed checkout. Without this branch those id-less panes each
/// render the "Starting terminal…" placeholder for the whole removal window,
/// which is seconds on a real checkout.
///
/// Deliberately gated on the terminals being *gone*, not on `closing` alone:
/// the merge/`before_remove`-hook phases also set the closing flag while the
/// terminals are still live and useful, and those must keep painting.
fn column_content(project: &ProjectData, closing: bool) -> ColumnContent {
let has_terminals = project
.layout
.as_ref()
.is_some_and(|layout| layout.has_terminal_ids());

if closing && !has_terminals {
ColumnContent::Closing
} else if project.layout.is_some() {
ColumnContent::Layout
} else if project.is_creating {
// Explicit mid-create marker set by the daemon while the git checkout
// runs and mirrored over the wire. NOT derived from a missing layout: a
// worktree whose last terminal the user closed is a legitimate bookmark
// (layout None) and must fall through to the empty state with its Start
// Terminal button, not the creating placeholder.
ColumnContent::Creating
} else {
ColumnContent::Empty
}
}

/// A single project column with header and layout
pub struct ProjectColumn {
/// Identifies which window-scoped slot on the shared `Workspace` this
Expand Down Expand Up @@ -785,6 +832,40 @@ impl ProjectColumn {
.child(header_body)
}

/// Render the closing state shown while a worktree is being torn down.
fn render_closing_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
let t = theme(cx);
v_flex()
.items_center()
.justify_center()
.size_full()
.gap(px(12.0))
.bg(rgb(t.bg_primary))
.child(
svg()
.path("icons/git-branch.svg")
.size(px(48.0))
.text_color(rgb(t.text_muted))
)
.child(
div()
.text_size(ui_text_xl(cx))
.text_color(rgb(t.text_secondary))
.child("Closing worktree\u{2026}")
)
.child(
div()
.text_size(ui_text_ms(cx))
.text_color(rgb(t.text_muted))
.max_w(px(240.0))
.text_center()
// Deliberately not "removing the checkout": the before_remove
// hook phase reaches this screen too, and that phase is still
// abortable — nothing has been deleted yet.
.child("This project disappears once the worktree is gone.")
)
}

/// Render empty state for bookmark projects (no terminal)
fn render_creating_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
let t = theme(cx);
Expand Down Expand Up @@ -975,15 +1056,11 @@ impl Render for ProjectColumn {

match project {
Some(project) => {
let has_layout = project.layout.is_some();

// Explicit mid-create marker set by the daemon while the git
// checkout runs and mirrored over the wire — shows the "Setting
// up worktree…" placeholder. NOT derived from `!has_layout`: a
// worktree whose last terminal the user closed is a legitimate
// bookmark (layout None) and must fall through to the empty state
// with its Start Terminal button, not the creating placeholder.
let is_creating = project.is_creating;
// Daemon-authoritative flag mirrored over the wire, OR'd with the
// initiating client's optimistic tracker — same pair the sidebar
// row reads for its "Closing…" label.
let is_closing = project.is_closing || workspace.is_project_closing(&project.id);
let content_kind = column_content(&project, is_closing);

// Soft tinted background based on folder color (when enabled)
let bg_color = if crate::settings::settings(cx).color_tinted_background {
Expand All @@ -997,25 +1074,26 @@ impl Render for ProjectColumn {
rgb(t.bg_primary)
};

// Content: layout, creating state, or empty bookmark state
let content = if has_layout {
self.ensure_layout_container(project.path.clone(), cx);
// Content: layout, closing/creating placeholder, or empty bookmark state
let content = match content_kind {
ColumnContent::Layout => {
self.ensure_layout_container(project.path.clone(), cx);

div()
.id("project-column-content")
.flex_1()
.min_h_0()
.overflow_hidden()
.when_some(self.layout_container.clone(), |d, container| {
d.child(AnyView::from(container).cached(
StyleRefinement::default().size_full(),
))
})
.into_any_element()
} else if is_creating {
self.render_creating_state(cx).into_any_element()
} else {
self.render_empty_state(cx).into_any_element()
div()
.id("project-column-content")
.flex_1()
.min_h_0()
.overflow_hidden()
.when_some(self.layout_container.clone(), |d, container| {
d.child(AnyView::from(container).cached(
StyleRefinement::default().size_full(),
))
})
.into_any_element()
}
ColumnContent::Closing => self.render_closing_state(cx).into_any_element(),
ColumnContent::Creating => self.render_creating_state(cx).into_any_element(),
ColumnContent::Empty => self.render_empty_state(cx).into_any_element(),
};

// Get current branch for commit log popover and update git header.
Expand Down Expand Up @@ -1094,9 +1172,9 @@ impl Render for ProjectColumn {

#[cfg(test)]
mod tests {
use super::project_header_display_name;
use super::{column_content, project_header_display_name, ColumnContent};
use crate::workspace::settings::HooksConfig;
use crate::workspace::state::{ProjectData, WorktreeMetadata};
use crate::workspace::state::{LayoutNode, ProjectData, SplitDirection, WorktreeMetadata};
use okena_core::theme::FolderColor;
use std::collections::HashMap;

Expand Down Expand Up @@ -1124,6 +1202,92 @@ mod tests {
}
}

/// A layout tree in the exact shape `prepare_background_worktree_removal`
/// leaves behind: structure intact, every leaf's terminal id nulled.
fn split_layout(terminal_ids: [Option<&str>; 2]) -> LayoutNode {
let children = terminal_ids
.into_iter()
.map(|id| {
let mut node = LayoutNode::new_terminal();
if let LayoutNode::Terminal { terminal_id, .. } = &mut node {
*terminal_id = id.map(str::to_string);
}
node
})
.collect();

LayoutNode::Split {
direction: SplitDirection::Horizontal,
sizes: vec![0.5, 0.5],
children,
}
}

#[test]
fn closing_worktree_with_nulled_terminal_ids_shows_closing_state() {
let mut project = project_with_name("feature-login");
project.layout = Some(split_layout([None, None]));
project.is_closing = true;

assert_eq!(
column_content(&project, true),
ColumnContent::Closing,
"id-less panes mid-teardown must not fall through to \"Starting terminal…\"",
);
}

#[test]
fn closing_worktree_keeps_painting_live_terminals() {
let mut project = project_with_name("feature-login");
project.layout = Some(split_layout([Some("t1"), None]));
project.is_closing = true;

assert_eq!(
column_content(&project, true),
ColumnContent::Layout,
"merge and before_remove-hook phases keep the terminals alive and visible",
);
}

#[test]
fn closing_bookmark_without_layout_shows_closing_not_empty_state() {
let mut project = project_with_name("feature-login");
project.is_closing = true;

assert_eq!(
column_content(&project, true),
ColumnContent::Closing,
"a closing project must not offer a Start Terminal button",
);
}

#[test]
fn optimistic_client_closing_flag_alone_triggers_closing_state() {
let project = project_with_name("feature-login");

assert_eq!(
column_content(&project, true),
ColumnContent::Closing,
"the initiating client marks closing in its tracker before the mirror catches up",
);
}

#[test]
fn non_closing_project_branches_are_unchanged() {
let mut project = project_with_name("feature-login");
assert_eq!(column_content(&project, false), ColumnContent::Empty);

project.is_creating = true;
assert_eq!(column_content(&project, false), ColumnContent::Creating);

project.layout = Some(split_layout([None, None]));
assert_eq!(
column_content(&project, false),
ColumnContent::Layout,
"a fresh worktree's uninitialized slots still render panes, not a placeholder",
);
}

#[test]
fn project_header_uses_worktree_project_name() {
let mut project = project_with_name("feature-login");
Expand Down
27 changes: 27 additions & 0 deletions crates/okena-layout/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ impl LayoutNode {
}
}

/// Whether any leaf still carries a terminal ID. Allocation-free counterpart
/// to `collect_terminal_ids().is_empty()` for render-path checks.
pub fn has_terminal_ids(&self) -> bool {
match self {
LayoutNode::Terminal { terminal_id, .. } => terminal_id.is_some(),
LayoutNode::Split { children, .. } | LayoutNode::Tabs { children, .. } => {
children.iter().any(LayoutNode::has_terminal_ids)
}
}
}

/// Clear terminal IDs except those in the `keep` set (e.g. hook terminals).
/// Kept terminals preserve their ID, minimized, and detached state.
pub fn clear_terminal_ids_except(&mut self, keep: &HashSet<&str>) {
Expand Down Expand Up @@ -1200,6 +1211,22 @@ mod tests {
assert_eq!(node.collect_terminal_ids(), vec!["t1"]);
}

#[test]
fn has_terminal_ids_matches_collect() {
let nested = hsplit(vec![
LayoutNode::new_terminal(),
tabs(vec![LayoutNode::new_terminal(), terminal("t1")]),
]);
assert!(nested.has_terminal_ids());

let mut cleared = nested;
cleared.clear_terminal_ids_except(&HashSet::new());
assert!(
!cleared.has_terminal_ids(),
"a tree stripped for worktree teardown carries no ids",
);
}

#[test]
fn clear_terminal_ids_resets_all() {
let mut node = hsplit(vec![
Expand Down
Loading