feat(sidebar): add projects as a declared layer beside the derived groups (#769)

* feat(sidebar): add projects as a declared layer beside the derived groups

The sidebar's repo groups are derived: a group's identity is a path
recomputed every frame from a leaf's cwd, it appears when a tab lands in
it and vanishes with its last tab. That layer cannot carry a name of its
own, cannot be created before a tab is opened in it, and orphans anything
keyed to it when a directory is renamed or moved.

Add a Project as a real entity on the workspace — an id, an optional
name, a root — and an optional reference to one on each tab. Nothing
probes it: a tab joins a project only by an explicit action, and a tab
that leaves one lands back in the group the probe would have put it in,
so declaration and inference never disagree and no third membership
state is needed.

The derived grouping, the cwd probe, the write-back and the
SidebarGrouping config are untouched; the only difference is the tab list
they are fed. A server that predates the projects feature ignores the new
array and serves today's sidebar.

Closes #756

* fix(sidebar): stop a searched row claiming a chord it does not own

A live search deliberately ignores a folded heading — the query is asking
about tabs — so the rail draws rows the chord order has taken out. The badge
was read from a `Vec<usize>` that started at zero, so every one of those rows
claimed ⌘1 while ⌘1 opened something else. It is `Option<usize>` now, built by
`badge_positions` off the same order `activate_visual` walks, and a row the
order left out wears no badge at all.

Also in the rail: the block loop reads "declared" off the section key rather
than the position it happens to sit at, and an unreachable `continue` for a
folded empty derived block is gone — `sidebar_sections` never makes one.

Projects:

- `MAX_PROJECTS` is held on the window side too. The machine refuses past it
  and a refusal resynchronizes, which would re-push the project this window
  kept and be refused again. Checked before the folder panel opens, so a full
  workspace says so before asking for a folder rather than after.
- `set_project_root` keeps the one-project-per-directory rule `declare_project`
  holds on the way in; pointing one project at another's folder reached the
  two-headers-that-mean-the-same-thing state by the back door.
- Opening a rename box over one already on another project commits it instead
  of dropping it with its subscription, which threw the typing away.

Sync:

- Project reordering moves after `retire_projects`. `to` indexes the machine's
  whole list, so a project on its way out pushed the survivors along and spelled
  a move for one already in place.
- `adopt_projects` reports whether it changed anything and the callers repaint
  when it did; it was mutating the window's list with nothing to notify.
- `migrate_panes` gets its doc comment back — `reconcile_projects` had been
  inserted between it and the comment describing it.

Dead `L10nKey::ProjectNew` removed: translated four times, used nowhere.

* fix(control): move the dialect to v8 for the project verbs

`CONTROL_VERSION`'s own doc says to move it whenever a variant is added to
`ControlRequest`, `ReplyOk` or `ControlEvent`, and says why the feature strings
are not a substitute: they cover what a peer can safely ignore — a field added
to a message it already decodes — while a variant it has never heard of fails
to decode and takes the whole link down with it.

The project verbs shipped behind a `projects` feature string instead. That
gates what a client *sends*, so a v7 server never saw a verb it could not read,
but nothing gates what a server *pushes*: a v7 client meeting a v8 server that
had grown a project would take the `ProjectCreated` delta, fail to decode the
frame, and lose the link — `read_until_closed` calls `fail_all` on any decode
error. Only the number can turn that pairing away at the handshake.

So the number moves and the feature goes. It was redundant even for the
direction it did cover: `MACHINE_TREE` and `PROJECTS` were pushed under the
same `services.machine.is_some()`, so within one build they were always equal
and only a cross-version pairing could tell them apart — which is exactly what
v8 now refuses at the handshake. Keeping both would be two mechanisms for one
job, and the weaker one silently covering half the problem.

Removed with it: `is_project_op` and the `pump` filter it fed.

Disk compatibility is a separate axis and is untouched — `Workspace::projects`
and `Tab::project` keep their `serde(default)`, and the test that reads a tree
written before either still passes.

Remote workspaces need their `tty7-server` pushed before they will connect.
That is the dialect-refusal path v7 was minted to make reachable: the parked
strip and its Update Server button.

Also: the two sidebar `+` buttons now fade in on their own heading's hover
rather than the whole rail's, so a control appears where the pointer is.
This commit is contained in:
l0ng-ai
2026-09-04 21:19:05 +08:00
committed by GitHub
parent 37be703d5b
commit cebd871cb4
19 changed files with 2595 additions and 188 deletions
+5
View File
@@ -6,17 +6,20 @@ pub fn two_workspace_machine() -> Machine {
id: WorkspaceId::new(),
name: Some("api".into()),
last_active: 0,
projects: Vec::new(),
tabs: vec![
Tab {
id: TabId::new(),
name: Some("build".into()),
sidebar_group: None,
project: None,
root: PaneNode::Leaf { pane: 1 },
},
Tab {
id: TabId::new(),
name: None,
sidebar_group: None,
project: None,
root: PaneNode::Split {
axis: Axis::Horizontal,
ratio: 0.5,
@@ -32,10 +35,12 @@ pub fn two_workspace_machine() -> Machine {
id: WorkspaceId::new(),
name: Some("web".into()),
last_active: 0,
projects: Vec::new(),
tabs: vec![Tab {
id: TabId::new(),
name: None,
sidebar_group: None,
project: None,
root: PaneNode::Leaf { pane: 5 },
}],
active_tab: None,
+377
View File
@@ -20,6 +20,8 @@ pub const MAX_WORKSPACES: usize = 1024;
pub const MAX_PANES: usize = 16 * 1024;
pub const MAX_PROJECTS: usize = 512;
#[cfg(not(test))]
pub const FACT_FLUSH_INTERVAL: Duration = Duration::from_secs(2);
@@ -48,6 +50,61 @@ impl std::fmt::Display for TabId {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProjectId(uuid::Uuid);
impl ProjectId {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
}
impl Default for ProjectId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for ProjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// A directory someone declared a project, held on the workspace it belongs to.
///
/// The sidebar's repo groups are derived: their identity *is* a path, they
/// appear when a tab lands in one and vanish with its last tab. A project is
/// the opposite of all three — it has an id of its own, it is created by an
/// explicit act, and it outlives having no tabs in it. That is what lets it
/// carry a name that has nothing to do with where it lives, and survive the
/// directory being renamed or moved.
///
/// `name` is `None` until someone names it, and reads as a title derived from
/// `root` the same way a group header does.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Project {
#[serde(default)]
pub id: ProjectId,
#[serde(default)]
pub name: Option<String>,
/// A path on the machine that serves this workspace, as a string for the
/// same reason `Tab::sidebar_group` is one: it is written on one machine
/// and read on another, and a `PathBuf` does not survive that crossing.
pub root: String,
}
impl Project {
pub fn at(root: impl Into<String>) -> Project {
Project {
id: ProjectId::new(),
name: None,
root: root.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Axis {
@@ -102,6 +159,12 @@ pub struct Workspace {
pub name: Option<String>,
#[serde(default)]
pub last_active: u64,
/// The projects declared in this workspace, in the order the sidebar shows
/// them. Empty on every workspace that predates them, and on every one
/// nobody has declared anything in — which is why the derived grouping
/// underneath stays exactly as it was.
#[serde(default)]
pub projects: Vec<Project>,
#[serde(default)]
pub tabs: Vec<Tab>,
#[serde(default)]
@@ -120,6 +183,7 @@ impl Default for Workspace {
id: WorkspaceId::new(),
name: None,
last_active: unix_now(),
projects: Vec::new(),
tabs: Vec::new(),
active_tab: None,
attachment: None,
@@ -135,6 +199,16 @@ pub struct Tab {
pub name: Option<String>,
#[serde(default)]
pub sidebar_group: Option<String>,
/// The project this tab was explicitly filed under, if any.
///
/// Only a user action writes this — nothing probes a cwd to fill it in.
/// Automatic filing is what `sidebar_group` above already does, one
/// section further down the sidebar, and a tab pulled out of a project
/// lands right back in the group that probe puts it in. That is why
/// `None` is enough to say "not in a project" and no third state is
/// needed to say "and don't put it back".
#[serde(default)]
pub project: Option<ProjectId>,
pub root: PaneNode,
}
@@ -144,6 +218,7 @@ impl Tab {
id: TabId::new(),
name: None,
sidebar_group: None,
project: None,
root: PaneNode::Leaf { pane },
}
}
@@ -408,6 +483,34 @@ pub enum LayoutDelta {
tab: TabId,
group: Option<String>,
},
ProjectCreated {
at: usize,
project: Project,
},
ProjectRenamed {
project: ProjectId,
name: Option<String>,
},
ProjectRerooted {
project: ProjectId,
root: String,
},
ProjectMoved {
project: ProjectId,
to: usize,
},
/// The project is gone, and with it every tab's membership in it. One
/// delta rather than a delete plus a `TabProjectChanged` per member: a
/// reader that applies this has to clear those references anyway, and a
/// reader that missed the follow-ups would be left pointing at a project
/// that no longer exists.
ProjectDeleted {
project: ProjectId,
},
TabProjectChanged {
tab: TabId,
project: Option<ProjectId>,
},
TabRestructured {
tab: Tab,
pane: Option<PaneRecord>,
@@ -742,6 +845,157 @@ impl MachineStore {
})
}
pub fn project_create(
&self,
workspace: WorkspaceId,
at: Option<usize>,
project: Project,
origin: Option<SubscriberId>,
) -> io::Result<Project> {
self.mutate(origin, |m| {
if m.workspaces
.iter()
.any(|w| w.projects.iter().any(|p| p.id == project.id))
{
return Err(refuse(format!("project {} already exists", project.id)));
}
let ws = find_workspace(m, workspace)?;
if ws.projects.len() >= MAX_PROJECTS {
return Err(refuse(format!(
"this workspace already holds {MAX_PROJECTS} projects"
)));
}
let at = at.unwrap_or(ws.projects.len()).min(ws.projects.len());
ws.projects.insert(at, project.clone());
Ok((
project.clone(),
vec![(workspace, LayoutDelta::ProjectCreated { at, project })],
))
})
}
pub fn project_rename(
&self,
workspace: WorkspaceId,
project: ProjectId,
name: Option<String>,
origin: Option<SubscriberId>,
) -> io::Result<()> {
self.mutate(origin, |m| {
let p = find_project(m, workspace, project)?;
p.name = name.clone();
Ok((
(),
vec![(workspace, LayoutDelta::ProjectRenamed { project, name })],
))
})
}
/// Points a project at another directory. The name survives, which is the
/// whole reason a project is not its path: a worktree that moved is the
/// same project afterwards.
pub fn project_set_root(
&self,
workspace: WorkspaceId,
project: ProjectId,
root: String,
origin: Option<SubscriberId>,
) -> io::Result<()> {
self.mutate(origin, |m| {
let p = find_project(m, workspace, project)?;
p.root = root.clone();
Ok((
(),
vec![(workspace, LayoutDelta::ProjectRerooted { project, root })],
))
})
}
pub fn project_move(
&self,
workspace: WorkspaceId,
project: ProjectId,
to: usize,
origin: Option<SubscriberId>,
) -> io::Result<()> {
self.mutate(origin, |m| {
let ws = find_workspace(m, workspace)?;
let from = ws
.projects
.iter()
.position(|p| p.id == project)
.ok_or_else(|| {
not_found(format!("workspace {workspace} has no project {project}"))
})?;
let moved = ws.projects.remove(from);
let to = to.min(ws.projects.len());
ws.projects.insert(to, moved);
Ok((
(),
vec![(workspace, LayoutDelta::ProjectMoved { project, to })],
))
})
}
/// Deletes a project and lets its tabs go. The tabs themselves stay open —
/// deleting a project says the grouping is over, not the work.
pub fn project_delete(
&self,
workspace: WorkspaceId,
project: ProjectId,
origin: Option<SubscriberId>,
) -> io::Result<()> {
self.mutate(origin, |m| {
let ws = find_workspace(m, workspace)?;
let at = ws
.projects
.iter()
.position(|p| p.id == project)
.ok_or_else(|| {
not_found(format!("workspace {workspace} has no project {project}"))
})?;
ws.projects.remove(at);
for tab in &mut ws.tabs {
if tab.project == Some(project) {
tab.project = None;
}
}
Ok((
(),
vec![(workspace, LayoutDelta::ProjectDeleted { project })],
))
})
}
pub fn tab_set_project(
&self,
workspace: WorkspaceId,
tab: TabId,
project: Option<ProjectId>,
origin: Option<SubscriberId>,
) -> io::Result<()> {
self.mutate(origin, |m| {
let ws = find_workspace(m, workspace)?;
if let Some(project) = project
&& !ws.projects.iter().any(|p| p.id == project)
{
return Err(not_found(format!(
"workspace {workspace} has no project {project}"
)));
}
let t = ws
.tabs
.iter_mut()
.find(|t| t.id == tab)
.ok_or_else(|| not_found(format!("workspace {workspace} has no tab {tab}")))?;
t.project = project;
Ok((
(),
vec![(workspace, LayoutDelta::TabProjectChanged { tab, project })],
))
})
}
pub fn pane_split(
&self,
workspace: WorkspaceId,
@@ -1151,6 +1405,18 @@ fn find_tab(m: &mut Machine, workspace: WorkspaceId, tab: TabId) -> io::Result<&
.ok_or_else(|| not_found(format!("workspace {workspace} has no tab {tab}")))
}
fn find_project(
m: &mut Machine,
workspace: WorkspaceId,
project: ProjectId,
) -> io::Result<&mut Project> {
let ws = find_workspace(m, workspace)?;
ws.projects
.iter_mut()
.find(|p| p.id == project)
.ok_or_else(|| not_found(format!("workspace {workspace} has no project {project}")))
}
fn heal_active_tab(ws: &mut Workspace, removed: usize) -> Option<TabId> {
let named = ws
.active_tab
@@ -1964,6 +2230,117 @@ mod tests {
);
}
#[test]
fn a_project_outlives_its_tabs_and_keeps_its_name_when_it_moves() {
let (store, _dir, ws, tab) = store_with_tab();
let project = store
.project_create(ws, None, Project::at("/repo/tty7"), None)
.unwrap();
store
.project_rename(ws, project.id, Some("套利研究".into()), None)
.unwrap();
store
.tab_set_project(ws, tab.id, Some(project.id), None)
.unwrap();
// The worktree moved. The project is not its path, so the name stays.
store
.project_set_root(ws, project.id, "/repo/026/tty7".into(), None)
.unwrap();
let held = store.workspace(ws).unwrap();
assert_eq!(held.projects[0].name.as_deref(), Some("套利研究"));
assert_eq!(held.projects[0].root, "/repo/026/tty7");
assert_eq!(held.tabs[0].project, Some(project.id));
// The last tab in it closes; the project is still there, empty.
store.tab_close(ws, tab.id, None).unwrap();
let held = store.workspace(ws).unwrap();
assert!(held.tabs.is_empty());
assert_eq!(
held.projects.len(),
1,
"a project is declared, so it does not vanish with its last tab"
);
}
#[test]
fn deleting_a_project_lets_its_tabs_go_without_closing_them() {
let (store, _dir, ws, tab) = store_with_tab();
let project = store
.project_create(ws, None, Project::at("/repo/tty7"), None)
.unwrap();
store
.tab_set_project(ws, tab.id, Some(project.id), None)
.unwrap();
let (_sub, heard) = recorded(&store);
store.project_delete(ws, project.id, None).unwrap();
let held = store.workspace(ws).unwrap();
assert!(held.projects.is_empty());
assert_eq!(held.tabs.len(), 1, "the work stays; only the grouping ends");
assert_eq!(held.tabs[0].project, None);
assert!(
matches!(
heard.lock().unwrap().as_slice(),
[(_, LayoutDelta::ProjectDeleted { .. })]
),
"one delta says both halves of it: a reader has to clear the \
references anyway, and follow-ups it missed would leave it \
pointing at a project that is gone"
);
}
#[test]
fn a_tab_cannot_be_filed_under_a_project_that_does_not_exist() {
let (store, _dir, ws, tab) = store_with_tab();
let stranger = ProjectId::new();
let refused = store.tab_set_project(ws, tab.id, Some(stranger), None);
assert_eq!(refused.unwrap_err().kind(), io::ErrorKind::NotFound);
assert_eq!(store.workspace(ws).unwrap().tabs[0].project, None);
}
#[test]
fn projects_reorder_and_refuse_a_duplicate_id() {
let (store, _dir, ws, _tab) = store_with_tab();
let a = store
.project_create(ws, None, Project::at("/w/a"), None)
.unwrap();
let b = store
.project_create(ws, None, Project::at("/w/b"), None)
.unwrap();
store.project_move(ws, b.id, 0, None).unwrap();
assert_eq!(
store
.workspace(ws)
.unwrap()
.projects
.iter()
.map(|p| p.root.clone())
.collect::<Vec<_>>(),
vec!["/w/b", "/w/a"]
);
let twice = store.project_create(ws, None, a.clone(), None);
assert_eq!(twice.unwrap_err().kind(), io::ErrorKind::InvalidInput);
}
/// A workspace written before projects existed reads back with none, and a
/// tab written before them belongs to none — which is the whole of the
/// migration.
#[test]
fn a_tree_that_predates_projects_reads_back_unchanged() {
let json = r#"{"workspaces":[{"id":"00000000-0000-4000-8000-000000000001",
"tabs":[{"id":"00000000-0000-4000-8000-000000000002",
"sidebar_group":"/repo/tty7","root":{"Leaf":{"pane":1}}}]}],"panes":[]}"#;
let machine: Machine = serde_json::from_str(json).unwrap();
assert!(machine.workspaces[0].projects.is_empty());
assert_eq!(machine.workspaces[0].tabs[0].project, None);
assert_eq!(
machine.workspaces[0].tabs[0].sidebar_group.as_deref(),
Some("/repo/tty7")
);
}
#[test]
fn splitting_and_closing_panes_reshapes_the_tree() {
let (store, _dir, ws, tab) = store_with_tab();
+6
View File
@@ -52,6 +52,8 @@ pub struct SessionTab {
pub pane: SessionPane,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sidebar_group: Option<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<crate::core::machine::ProjectId>,
#[serde(skip)]
pub tree_id: Option<crate::core::machine::TabId>,
}
@@ -61,6 +63,10 @@ pub struct SessionTab {
pub struct Session {
pub active: usize,
pub tabs: Vec<SessionTab>,
/// The workspace's declared projects, carried so a window restored from a
/// session alone — no machine to pull a tree from — still shows them.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub projects: Vec<crate::core::machine::Project>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+49 -2
View File
@@ -34,7 +34,17 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame};
/// refusing to kill a server it has nothing to replace with — can be exercised
/// against the v6 servers already deployed. A number is the only way to reach
/// that path, and a mismatch nobody can reproduce is a mismatch nobody can fix.
pub const CONTROL_VERSION: u32 = 7;
///
/// v8 adds the six project verbs (`TabSetProject`, `ProjectCreate`, `Rename`,
/// `SetRoot`, `Move`, `Delete`) and the six `LayoutDelta` variants that ride
/// back on `ControlEvent::Layout`. This first shipped behind a `projects`
/// feature string instead, which was the v6 mistake in a new shape: a feature
/// can gate what a client *sends*, so a v7 server never saw a verb it could not
/// read, but nothing gates what a server *pushes* — a v7 client meeting a v8
/// server that had grown a project took the delta, failed to decode the frame,
/// and lost the link exactly as described above. Only the number can turn that
/// pairing away at the handshake, which is why it is the number's job.
pub const CONTROL_VERSION: u32 = 8;
const DIALECT_MARKER: &str = "speaks control v";
@@ -123,7 +133,9 @@ pub use crate::host::{Entry, MTime, Meta, Output, SearchHit};
pub use crate::core::shells::{DetectedShell, ShellInventory};
pub use crate::core::machine::{Axis, LayoutDelta, Machine, PaneSeed, Side, Tab, TabId};
pub use crate::core::machine::{
Axis, LayoutDelta, Machine, PaneSeed, Project, ProjectId, Side, Tab, TabId,
};
pub use crate::core::session::WorkspaceId;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -268,6 +280,35 @@ pub enum ControlRequest {
tab: TabId,
group: Option<String>,
},
TabSetProject {
workspace: WorkspaceId,
tab: TabId,
project: Option<ProjectId>,
},
ProjectCreate {
workspace: WorkspaceId,
at: Option<u64>,
project: Project,
},
ProjectRename {
workspace: WorkspaceId,
project: ProjectId,
name: Option<String>,
},
ProjectSetRoot {
workspace: WorkspaceId,
project: ProjectId,
root: String,
},
ProjectMove {
workspace: WorkspaceId,
project: ProjectId,
to: u64,
},
ProjectDelete {
workspace: WorkspaceId,
project: ProjectId,
},
PaneSplit {
workspace: WorkspaceId,
pane: u64,
@@ -365,6 +406,12 @@ impl ControlRequest {
| TabRename { .. }
| TabMove { .. }
| TabSetGroup { .. }
| TabSetProject { .. }
| ProjectCreate { .. }
| ProjectRename { .. }
| ProjectSetRoot { .. }
| ProjectMove { .. }
| ProjectDelete { .. }
| PaneSplit { .. }
| PaneClose { .. }
| PaneSetRatio { .. }
+1 -1
View File
@@ -1092,7 +1092,7 @@ impl<'a> Installer<'a> {
/// server started some other way would be invisible to it. It is still the
/// better of the two answers available there. Linux's `comm` is not an answer
/// at all — the name truncated to 15 characters, one short of
/// `tty7-server-c7p6` — which is why the fallback stays a fallback and `/proc`
/// `tty7-server-c8p6` — which is why the fallback stays a fallback and `/proc`
/// keeps first refusal.
///
/// Neither arm reaches past the connecting user: `readlink` on another user's
+58
View File
@@ -765,6 +765,64 @@ fn run_request(
.tab_set_group(workspace, tab, group, conn.machine_origin)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::TabSetProject {
workspace,
tab,
project,
} => {
conn.machine()?
.tab_set_project(workspace, tab, project, conn.machine_origin)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::ProjectCreate {
workspace,
at,
project,
} => {
conn.machine()?.project_create(
workspace,
at.map(clamp_usize),
project,
conn.machine_origin,
)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::ProjectRename {
workspace,
project,
name,
} => {
conn.machine()?
.project_rename(workspace, project, name, conn.machine_origin)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::ProjectSetRoot {
workspace,
project,
root,
} => {
conn.machine()?
.project_set_root(workspace, project, root, conn.machine_origin)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::ProjectMove {
workspace,
project,
to,
} => {
conn.machine()?.project_move(
workspace,
project,
clamp_usize(to),
conn.machine_origin,
)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::ProjectDelete { workspace, project } => {
conn.machine()?
.project_delete(workspace, project, conn.machine_origin)?;
(ReplyOk::Unit, Vec::new())
}
ControlRequest::PaneSplit {
workspace,
pane,
+45 -2
View File
@@ -425,6 +425,10 @@ pub struct Tab {
pub(crate) diff_overlay: Option<crate::ui::diff_overlay::DiffOverlayState>,
pub(crate) code: Option<Box<crate::ui::code_editor::TabCode>>,
pub(crate) sidebar_group: std::cell::RefCell<Option<std::path::PathBuf>>,
/// The project this tab was filed under, or `None` for a tab the derived
/// grouping below the projects section still owns. Only a user action
/// writes it — see `Tab::project` on the machine tree.
pub(crate) project: std::cell::Cell<Option<tty7_core::core::machine::ProjectId>>,
pub(crate) overlay_top: OverlayTop,
/// Whether this tab's document fills the workspace or docks beside the
/// terminal, once the tab has been told. `None` follows `document_layout`
@@ -461,6 +465,7 @@ impl Tab {
overlay_top: OverlayTop::default(),
document_layout: None,
sidebar_group: std::cell::RefCell::new(None),
project: std::cell::Cell::new(None),
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
last_used: std::cell::Cell::new(0),
}
@@ -479,6 +484,7 @@ impl Tab {
sidebar_group: std::cell::RefCell::new(
tree.sidebar_group.clone().map(std::path::PathBuf::from),
),
project: std::cell::Cell::new(tree.project),
tree_id: std::cell::Cell::new(tree.id),
last_used: std::cell::Cell::new(0),
}
@@ -730,6 +736,13 @@ pub struct Tty7App {
pub(crate) right_panel_visible: bool,
pub(crate) right_panel_tab: RightPanelTab,
pub(crate) sidebar_collapsed: bool,
/// The rail blocks that are folded shut. See [`crate::ui::tab_sidebar::SidebarFold`].
pub(crate) sidebar_folded:
RefCell<std::collections::HashSet<crate::ui::tab_sidebar::SidebarFold>>,
/// The projects declared in this workspace, in sidebar order. The window's
/// own copy of `Workspace::projects` — `tree_sync` pushes edits made here
/// up to the machine and writes deltas from elsewhere back down.
pub(crate) projects: Vec<tty7_core::core::machine::Project>,
pub(crate) sidebar_scroll: gpui::ScrollHandle,
pub(crate) reorder: Rc<RefCell<Option<crate::ui::reorder::Reorder>>>,
/// The pane the pointer is over, so only that one offers its drag handle.
@@ -776,6 +789,7 @@ pub struct Tty7App {
window_bounds: Bounds<Pixels>,
pub(crate) workspace: WorkspaceId,
pub(crate) workspace_rename: Option<WorkspaceRename>,
pub(crate) project_rename: Option<crate::ui::projects::ProjectRename>,
window_title: std::cell::RefCell<String>,
pub(crate) connect: Option<crate::ui::remote_workspace::ConnectFlow>,
pub(crate) switcher: Option<crate::ui::switcher::Switcher>,
@@ -1199,6 +1213,10 @@ impl Tty7App {
apply_theme(Some(window), cx);
set_menus(cx);
let mut startup_error: Option<gpui::SharedString> = None;
let projects = session
.as_ref()
.map(|s| s.projects.clone())
.unwrap_or_default();
let (tabs, active) = match session {
None => match new_terminal(
pane_ws.clone(),
@@ -1257,6 +1275,7 @@ impl Tty7App {
let mut app = Self {
tabs,
active,
projects,
tab_use_seq: std::cell::Cell::new(0),
pending_tab: None,
font_size,
@@ -1324,6 +1343,7 @@ impl Tty7App {
right_panel_visible,
right_panel_tab,
sidebar_collapsed,
sidebar_folded: RefCell::new(std::collections::HashSet::new()),
sidebar_scroll: gpui::ScrollHandle::new(),
reorder: Rc::new(RefCell::new(None)),
pane_hover: Rc::new(Cell::new(None)),
@@ -1343,6 +1363,7 @@ impl Tty7App {
window_bounds: window_bounds_to_remember(window),
workspace,
workspace_rename: None,
project_rename: None,
window_title: std::cell::RefCell::new(String::new()),
connect: None,
switcher: None,
@@ -1600,6 +1621,10 @@ impl Tty7App {
self.refresh_shells(cx);
let font_size = self.font_size;
let pane_ws = self.window_workspace(cx);
// Replaced, not merged: this is how a window changes which workspace
// it is showing, and the projects it was showing belong to the one it
// is leaving.
self.projects = session.projects.clone();
let (tabs, active, dropped) = tabs_from_session(
pane_ws.as_ref(),
self.workspace,
@@ -1654,6 +1679,7 @@ impl Tty7App {
overlay_top: OverlayTop::default(),
document_layout: None,
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
project: std::cell::Cell::new(st.project),
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
last_used: std::cell::Cell::new(0),
},
@@ -3241,7 +3267,19 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.new_tab_with_cwd(Some(cwd), None, window, cx);
self.new_tab_with_cwd(Some(cwd), None, None, window, cx);
}
/// A new tab in `cwd`, filed under `project` from the moment it exists so
/// it never flickers through the derived grouping on its way there.
pub(crate) fn new_tab_at_in(
&mut self,
cwd: std::path::PathBuf,
project: Option<tty7_core::core::machine::ProjectId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.new_tab_with_cwd(Some(cwd), None, project, window, cx);
}
pub(crate) fn new_tab_with_shell(
@@ -3255,7 +3293,7 @@ impl Tty7App {
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
});
self.new_tab_with_cwd(cwd, shell, window, cx);
self.new_tab_with_cwd(cwd, shell, None, window, cx);
}
/// A shell taken out of the new-tab menu, wherever the ⌥ key said to put
@@ -3279,6 +3317,7 @@ impl Tty7App {
&mut self,
cwd: Option<std::path::PathBuf>,
shell: Option<ShellSpec>,
project: Option<tty7_core::core::machine::ProjectId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -3317,6 +3356,7 @@ impl Tty7App {
if let Some(group) = group {
*new_tab.sidebar_group.borrow_mut() = group;
}
new_tab.project.set(project);
self.tabs.insert(insert_at, new_tab);
self.active = insert_at;
self.focus_active(window, cx);
@@ -7720,6 +7760,7 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab {
name: tab.name.clone(),
pane: pane_to_session(&tab.pane, cx),
sidebar_group: tab.sidebar_group.borrow().clone(),
project: tab.project.get(),
tree_id: None,
}
}
@@ -7921,6 +7962,7 @@ fn tabs_from_session(
overlay_top: OverlayTop::default(),
document_layout: None,
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
project: std::cell::Cell::new(st.project),
tree_id: std::cell::Cell::new(
st.tree_id
.unwrap_or_else(tty7_core::core::machine::TabId::new),
@@ -9540,6 +9582,7 @@ mod ssh_rebuild_gpui_tests {
id: app.tabs[0].tree_id.get(),
name: None,
sidebar_group: None,
project: None,
root: PaneNode::Leaf { pane: 1 },
};
app.apply_layout_delta(
+7
View File
@@ -338,6 +338,7 @@ mod tests {
name: Some("build".into()),
tree_id: None,
sidebar_group: None,
project: None,
pane: leaf(Some("/work/getty")),
};
assert_eq!(closed_tab_label(&tab).as_deref(), Some("build"));
@@ -349,6 +350,7 @@ mod tests {
name: None,
tree_id: None,
sidebar_group: None,
project: None,
pane: leaf(Some("/work/getty")),
};
assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty"));
@@ -357,6 +359,7 @@ mod tests {
name: Some(" ".into()),
tree_id: None,
sidebar_group: None,
project: None,
pane: leaf(Some("/work/getty")),
};
assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty"));
@@ -368,6 +371,7 @@ mod tests {
name: None,
tree_id: None,
sidebar_group: None,
project: None,
pane: SessionPane::Split {
axis: crate::core::session::SessionAxis::Horizontal,
ratio: 0.5,
@@ -384,6 +388,7 @@ mod tests {
name: None,
tree_id: None,
sidebar_group: None,
project: None,
pane: leaf(None),
};
assert_eq!(closed_tab_label(&unnamed), None);
@@ -391,6 +396,7 @@ mod tests {
name: None,
tree_id: None,
sidebar_group: None,
project: None,
pane: leaf(Some("/")),
};
assert_eq!(closed_tab_label(&root), None);
@@ -402,6 +408,7 @@ mod tests {
name: Some("a".repeat(40)),
tree_id: None,
sidebar_group: None,
project: None,
pane: leaf(None),
};
let label = closed_tab_label(&tab).unwrap();
+13
View File
@@ -1779,6 +1779,19 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::TabUnnamedShell => "Shell {n}",
L10nKey::ShellDefault => "default",
L10nKey::SidebarScratchGroup => "Scratch",
L10nKey::SidebarProjectsHeading => "Projects",
L10nKey::SidebarTabsHeading => "Tabs",
L10nKey::ProjectNewTooltip => "Declare a folder a project",
L10nKey::ProjectRename => "Rename project…",
L10nKey::ProjectSetFolder => "Change folder…",
L10nKey::ProjectDelete => "Remove project",
L10nKey::ProjectNewTab => "New tab here",
L10nKey::ProjectEmpty => "open a tab here",
L10nKey::TabProjectAddTo => "Move to project",
L10nKey::TabProjectRemove => "Remove from project",
L10nKey::TabProjectFromFolder => "New project from this folder",
L10nKey::ProjectNoFolder => "That tab has no folder to make a project from",
L10nKey::ProjectLimitReached => "This workspace already holds {max} projects",
L10nKey::TabContextCloseTab => "Close Tab",
L10nKey::TabContextCloseTabsBelow => "Close Tabs Below",
L10nKey::AppAgentHooksOpFailed => "Failed: {error}",
+15
View File
@@ -1850,6 +1850,21 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::TabUnnamedShell => "シェル {n}",
L10nKey::ShellDefault => "デフォルト",
L10nKey::SidebarScratchGroup => "スクラッチ",
L10nKey::SidebarProjectsHeading => "プロジェクト",
L10nKey::SidebarTabsHeading => "タブ",
L10nKey::ProjectNewTooltip => "フォルダをプロジェクトにする",
L10nKey::ProjectRename => "プロジェクト名を変更…",
L10nKey::ProjectSetFolder => "フォルダを変更…",
L10nKey::ProjectDelete => "プロジェクトを削除",
L10nKey::ProjectNewTab => "ここで新規タブ",
L10nKey::ProjectEmpty => "ここでタブを開く",
L10nKey::TabProjectAddTo => "プロジェクトへ移動",
L10nKey::TabProjectRemove => "プロジェクトから外す",
L10nKey::TabProjectFromFolder => "このフォルダで新規プロジェクト",
L10nKey::ProjectNoFolder => "このタブにはプロジェクトにできるフォルダがありません",
L10nKey::ProjectLimitReached => {
"このワークスペースには既に {max} 個のプロジェクトがあります"
}
L10nKey::TabContextCloseTab => "タブを閉じる",
L10nKey::TabContextCloseTabsBelow => "下のタブを閉じる",
L10nKey::AppAgentHooksOpFailed => "失敗: {error}",
+13
View File
@@ -1004,6 +1004,19 @@ l10n_keys! {
TabUnnamedShell,
ShellDefault,
SidebarScratchGroup,
SidebarProjectsHeading,
SidebarTabsHeading,
ProjectNewTooltip,
ProjectRename,
ProjectSetFolder,
ProjectDelete,
ProjectNewTab,
ProjectEmpty,
TabProjectAddTo,
TabProjectRemove,
TabProjectFromFolder,
ProjectNoFolder,
ProjectLimitReached,
TabContextCloseTab,
TabContextCloseTabsBelow,
TabContextMarkUnread,
+13
View File
@@ -1689,6 +1689,19 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::TabUnnamedShell => "终端 {n}",
L10nKey::ShellDefault => "默认",
L10nKey::SidebarScratchGroup => "草稿",
L10nKey::SidebarProjectsHeading => "项目",
L10nKey::SidebarTabsHeading => "标签页",
L10nKey::ProjectNewTooltip => "把一个目录声明为项目",
L10nKey::ProjectRename => "重命名项目…",
L10nKey::ProjectSetFolder => "更改目录…",
L10nKey::ProjectDelete => "移除项目",
L10nKey::ProjectNewTab => "在此新建标签页",
L10nKey::ProjectEmpty => "在此打开一个标签页",
L10nKey::TabProjectAddTo => "移入项目",
L10nKey::TabProjectRemove => "移出项目",
L10nKey::TabProjectFromFolder => "以此目录新建项目",
L10nKey::ProjectNoFolder => "这个标签页没有可用来建项目的目录",
L10nKey::ProjectLimitReached => "这个工作区已经有 {max} 个项目了",
L10nKey::TabContextCloseTab => "关闭标签页",
L10nKey::TabContextCloseTabsBelow => "关闭下方标签页",
L10nKey::AppAgentHooksOpFailed => "失败:{error}",
+62
View File
@@ -170,6 +170,22 @@ impl MachineMirrors {
});
}
/// The projects a window just pushed, recorded for the same reason the
/// tabs above are: the deltas that carried them are not sent back to the
/// window that raised them, so this mirror would never hear about them.
pub fn note_synced_projects(
cx: &mut App,
host: HostId,
machine_ws: WorkspaceId,
projects: Vec<tty7_core::core::machine::Project>,
) {
Self::write(cx, host, move |machine| {
if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == machine_ws) {
ws.projects = projects.clone();
}
});
}
/// Records for the panes this window itself seeded into the machine.
///
/// A window is left out of the deltas its own ops raise, and `TabCreated`
@@ -306,6 +322,51 @@ fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) ->
t.sidebar_group = group.clone();
true
}
LayoutDelta::TabProjectChanged { tab, project } => {
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
return false;
};
t.project = *project;
true
}
LayoutDelta::ProjectCreated { at, project } => {
ws.projects.retain(|p| p.id != project.id);
let at = (*at).min(ws.projects.len());
ws.projects.insert(at, project.clone());
true
}
LayoutDelta::ProjectRenamed { project, name } => {
let Some(p) = ws.projects.iter_mut().find(|p| p.id == *project) else {
return false;
};
p.name = name.clone();
true
}
LayoutDelta::ProjectRerooted { project, root } => {
let Some(p) = ws.projects.iter_mut().find(|p| p.id == *project) else {
return false;
};
p.root = root.clone();
true
}
LayoutDelta::ProjectMoved { project, to } => {
let Some(from) = ws.projects.iter().position(|p| p.id == *project) else {
return false;
};
let moved = ws.projects.remove(from);
ws.projects.insert((*to).min(ws.projects.len()), moved);
true
}
LayoutDelta::ProjectDeleted { project } => {
let before = ws.projects.len();
ws.projects.retain(|p| p.id != *project);
for tab in &mut ws.tabs {
if tab.project == Some(*project) {
tab.project = None;
}
}
ws.projects.len() != before
}
LayoutDelta::TabMoved { tab, to } => {
let Some(from) = ws.tabs.iter().position(|t| t.id == *tab) else {
return false;
@@ -919,6 +980,7 @@ mod tests {
id: tab_id,
name: None,
sidebar_group: None,
project: None,
root: PaneNode::Split {
axis: Axis::Vertical,
ratio: 0.5,
+1
View File
@@ -25,6 +25,7 @@ pub mod pending_pane;
pub mod perf;
pub mod prefill;
pub mod presets;
pub mod projects;
pub mod remote_connect;
pub mod remote_workspace;
pub mod reorder;
+388
View File
@@ -0,0 +1,388 @@
//! Projects: the declared half of the sidebar.
//!
//! A repo group in the sidebar is derived — its identity is a path, it appears
//! when a tab lands in it and vanishes with its last tab. A project is
//! declared: it has an id, someone made it on purpose, it carries a name that
//! owes nothing to its directory, and it stays when the last tab in it closes.
//!
//! Nothing here probes anything. A tab joins a project because someone said
//! so, and leaves it the same way; the filing-by-cwd that used to be the only
//! grouping there was still runs, one section further down, over the tabs no
//! project has claimed.
use std::path::PathBuf;
use gpui::{Context, Entity, Subscription, Window};
use gpui_component::WindowExt as _;
use gpui_component::input::{InputEvent, InputState};
use tty7_core::core::machine::{MAX_PROJECTS, Project, ProjectId};
use crate::core::session::WorkspaceStore;
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t, t_fmt};
/// A project header that has turned into a text box.
pub(crate) struct ProjectRename {
pub(crate) project: ProjectId,
pub(crate) input: Entity<InputState>,
_subs: Vec<Subscription>,
}
impl Tty7App {
pub(crate) fn project(&self, id: ProjectId) -> Option<&Project> {
self.projects.iter().find(|p| p.id == id)
}
/// The folder a tab would make a project out of: its repo home when the
/// probe found one, otherwise whatever directory it is sitting in.
pub(crate) fn tab_project_root(
&self,
index: usize,
window: &Window,
cx: &gpui::App,
) -> Option<PathBuf> {
let tab = self.tabs.get(index)?;
if let Some(group) = tab.sidebar_group.borrow().clone() {
return Some(group);
}
tab.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).spawnable_cwd())
}
/// Declares `root` a project, or hands back the one already on it.
///
/// `None` means the workspace is full. The machine refuses past
/// [`MAX_PROJECTS`] too, and a refusal there resynchronizes — which would
/// re-push the project this window kept and be refused again, so the limit
/// has to be held on this side of the wire as well.
pub(crate) fn declare_project(
&mut self,
root: PathBuf,
cx: &mut Context<Self>,
) -> Option<ProjectId> {
let root = root.to_string_lossy().into_owned();
// One project per directory: declaring the same folder twice would put
// two headers on screen that mean the same thing, and the second one
// could never be told apart from the first.
if let Some(existing) = self.projects.iter().find(|p| p.root == root) {
return Some(existing.id);
}
if self.projects.len() >= MAX_PROJECTS {
return None;
}
let project = Project::at(root);
let id = project.id;
self.projects.push(project);
self.save_session(cx);
cx.notify();
Some(id)
}
/// The `+` on the projects heading: pick a folder and declare it.
///
/// The native panel browses the machine this window runs on, which on a
/// remote workspace is the wrong machine — so there the folder comes from
/// the tab that is open instead. A button that opens a panel onto paths
/// the workspace cannot reach would be worse than one that guesses.
pub(crate) fn new_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.workspace_is_remote(cx) {
self.project_from_tab(self.active, window, cx);
return;
}
// Checked before the panel opens rather than after it closes: being
// told the workspace is full is one thing, being told it after picking
// a folder is another.
if !self.room_for_a_project(window, cx) {
return;
}
self.pick_folder(cx, |this, path, cx| {
this.declare_project(path, cx);
});
}
/// Whether another project fits, saying so on the way out if not.
fn room_for_a_project(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
if self.projects.len() < MAX_PROJECTS {
return true;
}
window.push_notification(
t_fmt(
L10nKey::ProjectLimitReached,
&[("max", &MAX_PROJECTS.to_string())],
),
cx,
);
false
}
pub(crate) fn pick_project_root(&mut self, project: ProjectId, cx: &mut Context<Self>) {
self.pick_folder(cx, move |this, path, cx| {
this.set_project_root(project, path, cx);
});
}
fn pick_folder(
&mut self,
cx: &mut Context<Self>,
then: impl FnOnce(&mut Self, PathBuf, &mut Context<Self>) + 'static,
) {
debug_assert!(
!self.workspace_is_remote(cx),
"the folder panel browses this machine; a remote workspace has to \
reach its folders another way"
);
let rx = cx.prompt_for_paths(gpui::PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: None,
});
cx.spawn(async move |this, cx| {
let Ok(Ok(Some(paths))) = rx.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
let _ = this.update(cx, |this, cx| then(this, path, cx));
})
.detach();
}
pub(crate) fn workspace_is_remote(&self, cx: &gpui::App) -> bool {
WorkspaceStore::all(cx)
.get(self.workspace)
.is_some_and(|w| w.is_remote())
}
pub(crate) fn set_project_root(
&mut self,
project: ProjectId,
root: PathBuf,
cx: &mut Context<Self>,
) {
let root = root.to_string_lossy().into_owned();
// The same one-project-per-directory rule `declare_project` holds on
// the way in. Pointing one project at another's folder would reach the
// state that rule exists to keep out — two headers that mean the same
// thing — by the back door.
if self
.projects
.iter()
.any(|p| p.id != project && p.root == root)
{
return;
}
let Some(p) = self.projects.iter_mut().find(|p| p.id == project) else {
return;
};
if p.root == root {
return;
}
p.root = root;
self.save_session(cx);
cx.notify();
}
/// Deletes a project. Its tabs stay open and fall back to the derived
/// grouping — deleting a project says the grouping is over, not the work.
pub(crate) fn delete_project(&mut self, project: ProjectId, cx: &mut Context<Self>) {
let before = self.projects.len();
self.projects.retain(|p| p.id != project);
if self.projects.len() == before {
return;
}
for tab in &self.tabs {
if tab.project.get() == Some(project) {
tab.project.set(None);
}
}
self.save_session(cx);
cx.notify();
}
pub(crate) fn set_tab_project(
&mut self,
index: usize,
project: Option<ProjectId>,
cx: &mut Context<Self>,
) {
let Some(tab) = self.tabs.get(index) else {
return;
};
if tab.project.get() == project {
return;
}
tab.project.set(project);
self.save_session(cx);
cx.notify();
}
/// Declares the tab's folder a project and files the tab under it in one
/// act — the shortest path from "I am working here" to a named project.
pub(crate) fn project_from_tab(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(root) = self.tab_project_root(index, window, cx) else {
window.push_notification(t(L10nKey::ProjectNoFolder), cx);
return;
};
if !self
.projects
.iter()
.any(|p| p.root == root.to_string_lossy())
&& !self.room_for_a_project(window, cx)
{
return;
}
let Some(id) = self.declare_project(root, cx) else {
return;
};
self.set_tab_project(index, Some(id), cx);
self.start_project_rename(id, window, cx);
}
pub(crate) fn new_tab_in_project(
&mut self,
project: ProjectId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(root) = self.project(project).map(|p| PathBuf::from(&p.root)) else {
return;
};
self.new_tab_at_in(root, Some(project), window, cx);
}
pub(crate) fn start_project_rename(
&mut self,
project: ProjectId,
window: &mut Window,
cx: &mut Context<Self>,
) {
// A box already open on another project is committed, not dropped.
// Dropping it would take the subscription with it, so the typing that
// was sitting in it would never reach `commit_project_rename` — the
// rename would be silently thrown away by opening a second one.
if self
.project_rename
.as_ref()
.is_some_and(|r| r.project != project)
{
self.commit_project_rename(window, cx);
}
let Some(p) = self.project(project) else {
return;
};
// The box opens on the name the header is showing, derived title and
// all, so renaming an unnamed project starts from what it reads as
// rather than from nothing.
let current = p
.name
.clone()
.unwrap_or_else(|| derived_project_name(&self.projects, project).unwrap_or_default());
let input = Self::rename_box(current, window, cx);
let subs = vec![cx.subscribe_in(
&input,
window,
|this, _input, ev: &InputEvent, window, cx| match ev {
InputEvent::PressEnter { .. } | InputEvent::Blur => {
this.commit_project_rename(window, cx)
}
_ => {}
},
)];
self.project_rename = Some(ProjectRename {
project,
input,
_subs: subs,
});
cx.notify();
}
pub(crate) fn commit_project_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(rename) = self.project_rename.take() else {
return;
};
let value = rename.input.read(cx).value().trim().to_string();
// Typing the derived title back is not a rename: it would pin a name
// that is already what the header says, and the project would then
// stop following its folder for no visible reason.
let derived = derived_project_name(&self.projects, rename.project);
let name = match value {
v if v.is_empty() => None,
v if Some(&v) == derived.as_ref() => None,
v => Some(v),
};
if let Some(p) = self.projects.iter_mut().find(|p| p.id == rename.project)
&& p.name != name
{
p.name = name;
self.save_session(cx);
}
self.focus_active(window, cx);
cx.notify();
}
}
/// What each project's header reads when nobody has named it: the last
/// component of its root, widened a component at a time until no two projects
/// read the same. The same walk-up the derived group headers do, over the
/// project roots instead of the repo roots.
pub(crate) fn project_names(projects: &[Project]) -> Vec<String> {
let roots: Vec<PathBuf> = projects.iter().map(|p| PathBuf::from(&p.root)).collect();
let refs: Vec<&PathBuf> = roots.iter().collect();
let derived = crate::ui::tab_sidebar::group_names(&refs);
projects
.iter()
.zip(derived)
.map(|(p, fallback)| match p.name.as_deref().map(str::trim) {
Some(name) if !name.is_empty() => name.to_string(),
_ => fallback,
})
.collect()
}
fn derived_project_name(projects: &[Project], project: ProjectId) -> Option<String> {
let at = projects.iter().position(|p| p.id == project)?;
let roots: Vec<PathBuf> = projects.iter().map(|p| PathBuf::from(&p.root)).collect();
let refs: Vec<&PathBuf> = roots.iter().collect();
crate::ui::tab_sidebar::group_names(&refs)
.into_iter()
.nth(at)
}
#[cfg(test)]
mod tests {
use super::*;
fn project(root: &str, name: Option<&str>) -> Project {
Project {
name: name.map(str::to_string),
..Project::at(root)
}
}
#[test]
fn an_unnamed_project_reads_as_its_folder_and_disambiguates() {
let projects = vec![
project("/home/u/work/app", None),
project("/home/u/fork/app", None),
project("/home/u/tty7", Some("套利研究")),
];
assert_eq!(
project_names(&projects),
vec!["work/app", "fork/app", "套利研究"]
);
}
#[test]
fn a_blank_name_falls_back_rather_than_rendering_nothing() {
let projects = vec![project("/w/repo", Some(" "))];
assert_eq!(project_names(&projects), vec!["repo"]);
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
use gpui::{Axis, Bounds, Pixels, Point, Styled, px};
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use std::rc::Rc;
use tty7_core::core::machine::TabId;
@@ -95,7 +94,7 @@ pub(crate) fn suspend(state: &ReorderState, yes: bool) {
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) enum Surface {
Strip,
SidebarRows(Option<PathBuf>),
SidebarRows(crate::ui::tab_sidebar::SectionKey),
SidebarGroups,
}
+729 -127
View File
File diff suppressed because it is too large Load Diff
+172 -12
View File
@@ -846,6 +846,24 @@ pub(crate) fn select_workspace_action(index: usize) -> Option<Box<dyn gpui::Acti
})
}
/// What a tab's context menu is built from, read out of the app in one go so
/// the borrow ends before the menu starts being assembled — a submenu is built
/// through the same `cx` the app was read from.
struct Facts {
tab_count: usize,
cwd: Option<String>,
agent_here: bool,
agent_done: bool,
in_repo: bool,
agent_session: Option<(
gpui::Entity<crate::terminal::view::TerminalView>,
crate::ui::app::TabAgentSession,
)>,
projects: Vec<(tty7_core::core::machine::ProjectId, String)>,
filed_under: Option<tty7_core::core::machine::ProjectId>,
has_folder: bool,
}
impl Tty7App {
pub(crate) const AVATAR_PX: f32 = 20.0;
@@ -1313,6 +1331,31 @@ impl Tty7App {
})
}
/// The new-tab button as the rail's TABS heading wears it: the same
/// dropdown, sized to sit in an 11px header rather than in the title bar.
pub(crate) fn new_tab_heading_button(
&self,
id: &'static str,
cx: &Context<Self>,
) -> impl IntoElement + use<> {
let app = cx.entity().downgrade();
hit_target(
Button::new(id)
.icon(Icon::new(IconName::Plus))
.ghost()
.xsmall(),
)
.tooltip(chord_hint(t(L10nKey::AppMenuNewTab), "NewTab", cx))
.dropdown_menu(move |menu, window, cx| {
let Some(this) = app.upgrade() else {
return menu;
};
this.read(cx)
.new_tab_menu_rows(app.clone(), cx)
.build(menu, window)
})
}
/// What the menu offers, read off the app as the menu opens — the builder
/// runs on the popup's own entity, so the rows carry a weak handle back.
fn new_tab_menu_rows(&self, app: gpui::WeakEntity<Self>, cx: &App) -> NewTabMenu {
@@ -1329,20 +1372,98 @@ impl Tty7App {
}
}
/// What a project header offers. Everything here is a deliberate act on
/// the project itself — nothing about it is ever inferred, which is the
/// whole difference between this block of rows and the derived ones below
/// it in the rail.
pub(crate) fn project_context_menu(
menu: PopupMenu,
project: tty7_core::core::machine::ProjectId,
app: &gpui::WeakEntity<Self>,
_window: &mut Window,
cx: &mut Context<PopupMenu>,
) -> PopupMenu {
let Some(entity) = app.upgrade() else {
return menu;
};
// The folder panel browses the machine this window runs on, so a
// remote workspace is offered everything here except that.
let local = !entity.read(cx).workspace_is_remote(cx);
menu.min_w(px(200.))
.item(PopupMenuItem::new(t(L10nKey::ProjectNewTab)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.new_tab_in_project(project, window, cx));
}
}))
.separator()
.item(PopupMenuItem::new(t(L10nKey::ProjectRename)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.start_project_rename(project, window, cx)
});
}
}))
.item(
PopupMenuItem::new(t(L10nKey::ProjectSetFolder))
.disabled(!local)
.on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.pick_project_root(project, cx));
}
}),
)
.separator()
.item(PopupMenuItem::new(t(L10nKey::ProjectDelete)).on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.delete_project(project, cx));
}
}))
}
pub(crate) fn tab_context_menu(
menu: PopupMenu,
index: usize,
below_wording: bool,
app: &gpui::WeakEntity<Self>,
window: &Window,
cx: &App,
window: &mut Window,
cx: &mut Context<PopupMenu>,
) -> PopupMenu {
let Some(entity) = app.upgrade() else {
return menu;
};
let this = entity.read(cx);
let tab_count = this.tabs.len();
let cwd = this.tab_cwd_text(index, window, cx);
// Everything the rows are built from is read out of the app up front
// and the borrow released: a submenu is built through `cx` itself, and
// holding a read of the app across that call is a borrow conflict.
let Facts {
tab_count,
cwd,
agent_here,
agent_done,
in_repo,
agent_session,
projects,
filed_under,
has_folder,
} = {
let this = entity.read(cx);
let tab = this.tabs.get(index);
Facts {
tab_count: this.tabs.len(),
cwd: this.tab_cwd_text(index, window, cx),
agent_here: tab.is_some_and(|t| t.agent(cx).is_some()),
agent_done: tab.and_then(|t| t.agent_status(cx))
== Some(crate::core::cli_agent::AgentStatus::Done),
in_repo: this.tab_is_in_repo(index, window, cx),
agent_session: this.tab_agent_session(index, window, cx),
projects: this.named_projects(),
filed_under: tab.and_then(|t| t.project.get()),
has_folder: this.tab_project_root(index, window, cx).is_some(),
}
};
let has_cwd = cwd.is_some();
let mut menu = menu.min_w(px(200.));
@@ -1363,14 +1484,55 @@ impl Tty7App {
}),
);
let tab = this.tabs.get(index);
if tab.is_some_and(|t| t.agent(cx).is_some()) {
let done = tab.and_then(|t| t.agent_status(cx))
== Some(crate::core::cli_agent::AgentStatus::Done);
// Where this tab is filed. Only a user action ever writes it, so
// these are the only way in and out of a project.
if !projects.is_empty() {
let app = app.clone();
menu = menu.submenu(
t(L10nKey::TabProjectAddTo),
window,
cx,
move |mut sub, _w, _cx| {
for (id, name) in &projects {
let (id, app) = (*id, app.clone());
sub = sub.item(
PopupMenuItem::new(SharedString::from(name.clone()))
.disabled(filed_under == Some(id))
.on_click(move |_, _window, cx| {
let _ = app.update(cx, |this, cx| {
this.set_tab_project(index, Some(id), cx)
});
}),
);
}
sub
},
);
}
if filed_under.is_some() {
menu = menu.item(PopupMenuItem::new(t(L10nKey::TabProjectRemove)).on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.set_tab_project(index, None, cx));
}
}));
}
menu = menu.item(
PopupMenuItem::new(t(L10nKey::TabProjectFromFolder))
.disabled(!has_folder)
.on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.project_from_tab(index, window, cx));
}
}),
);
if agent_here {
menu = menu.item(
PopupMenuItem::new(t(L10nKey::TabContextMarkUnread))
.action(Box::new(MarkTabUnread))
.disabled(!done)
.disabled(!agent_done)
.on_click({
let app = app.clone();
move |_, _window, cx| {
@@ -1380,7 +1542,6 @@ impl Tty7App {
);
}
let in_repo = this.tab_is_in_repo(index, window, cx);
if in_repo {
menu = menu.separator().item(
PopupMenuItem::new(t(L10nKey::AppMenuNewWorktreeTab))
@@ -1395,7 +1556,6 @@ impl Tty7App {
);
}
let agent_session = this.tab_agent_session(index, window, cx);
if let Some((source, session)) = &agent_session
&& let Some(label) = session.fork_label
{
+640 -42
View File
File diff suppressed because it is too large Load Diff