Skip to content

Commit 426d482

Browse files
matej21claude
andcommitted
fix: remote client renders project folders and worktrees as flat list
Add worktree_info and worktree_ids to ApiProject so the server transmits parent-child worktree relationships. Rewrite client-side materialization to replicate the server's folder structure (individual FolderData per server folder) instead of flattening everything into a single wrapper folder per connection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ddd8bb9 commit 426d482

File tree

7 files changed

+119
-72
lines changed

7 files changed

+119
-72
lines changed

crates/okena-core/src/api.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ pub struct ApiProject {
4848
pub folder_color: FolderColor,
4949
#[serde(default, skip_serializing_if = "Vec::is_empty")]
5050
pub services: Vec<ApiServiceInfo>,
51+
#[serde(default, skip_serializing_if = "Option::is_none")]
52+
pub worktree_info: Option<ApiWorktreeMetadata>,
53+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
54+
pub worktree_ids: Vec<String>,
55+
}
56+
57+
#[derive(Clone, Debug, Serialize, Deserialize)]
58+
pub struct ApiWorktreeMetadata {
59+
pub parent_project_id: String,
60+
#[serde(default, skip_serializing_if = "Option::is_none")]
61+
pub color_override: Option<FolderColor>,
5162
}
5263

5364
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -380,6 +391,8 @@ mod tests {
380391
git_status: None,
381392
folder_color: FolderColor::Blue,
382393
services: vec![],
394+
worktree_info: None,
395+
worktree_ids: vec![],
383396
}],
384397
focused_project_id: Some("p1".into()),
385398
fullscreen_terminal: None,

crates/okena-core/src/client/state.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ mod tests {
162162
git_status: None,
163163
folder_color: Default::default(),
164164
services: vec![],
165+
worktree_info: None,
166+
worktree_ids: vec![],
165167
}
166168
}
167169

crates/okena-views-sidebar/src/folder_list.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use okena_workspace::state::FolderData;
1616

1717
impl Sidebar {
1818
/// Send a reorder action to the remote server when a project is reordered
19-
/// within a "remote-folder:{conn_id}" on the client.
19+
/// within a remote folder on the client.
2020
fn send_remote_reorder(this: &mut Self, conn_id: &str, prefixed_project_id: &str, new_index: usize, cx: &mut App) {
2121
let server_project_id = okena_core::client::strip_prefix(prefixed_project_id, conn_id);
2222

@@ -283,9 +283,12 @@ impl Sidebar {
283283
ws.move_project_to_folder(&drag.project_id, &folder_id, Some(pos), cx);
284284
});
285285
// Send reorder to server for remote folders
286-
if folder_id.starts_with("remote-folder:") {
287-
if let Some(conn_id) = folder_id.strip_prefix("remote-folder:") {
288-
Self::send_remote_reorder(this, conn_id, &drag.project_id, pos, cx);
286+
if folder_id.starts_with("remote:") {
287+
// Folder ID is "remote:{conn_id}:{folder_id}" — extract conn_id
288+
if let Some(rest) = folder_id.strip_prefix("remote:") {
289+
if let Some(conn_id) = rest.split(':').next() {
290+
Self::send_remote_reorder(this, conn_id, &drag.project_id, pos, cx);
291+
}
289292
}
290293
}
291294
}

crates/okena-workspace/src/persistence.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -792,9 +792,9 @@ mod tests {
792792

793793
let mut data = make_workspace(
794794
vec![local, remote1, remote2],
795-
vec!["local1", "remote-folder:conn1"],
795+
vec!["local1", "remote:conn1:folder1"],
796796
vec![FolderData {
797-
id: "remote-folder:conn1".to_string(),
797+
id: "remote:conn1:folder1".to_string(),
798798
name: "Server 1".to_string(),
799799
project_ids: vec!["remote:conn1:p1".to_string(), "remote:conn1:p2".to_string()],
800800
collapsed: false,

crates/okena-workspace/src/state.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ impl WorkspaceData {
6363
version: self.version,
6464
projects: self.projects.iter().filter(|p| !p.is_remote).cloned().collect(),
6565
project_order: self.project_order.iter()
66-
.filter(|id| !id.starts_with("remote-folder:") && !remote_ids.contains(id.as_str()))
66+
.filter(|id| !id.starts_with("remote:") && !remote_ids.contains(id.as_str()))
6767
.cloned().collect(),
6868
project_widths: self.project_widths.iter()
6969
.filter(|(id, _)| !remote_ids.contains(id.as_str()))
@@ -72,7 +72,7 @@ impl WorkspaceData {
7272
.filter(|(id, _)| !remote_ids.contains(id.as_str()))
7373
.map(|(k, v)| (k.clone(), *v)).collect(),
7474
folders: self.folders.iter()
75-
.filter(|f| !f.id.starts_with("remote-folder:"))
75+
.filter(|f| !f.id.starts_with("remote:"))
7676
.cloned().collect(),
7777
}
7878
}
@@ -897,17 +897,16 @@ impl Workspace {
897897
/// Remove all remote projects (and their folder) for a given connection_id.
898898
#[allow(dead_code)]
899899
pub fn remove_remote_projects(&mut self, connection_id: &str, cx: &mut Context<Self>) {
900-
let folder_id = format!("remote-folder:{}", connection_id);
901900
let prefix = format!("remote:{}:", connection_id);
902901

903902
// Remove projects
904903
self.data.projects.retain(|p| !p.id.starts_with(&prefix));
905904

906-
// Remove folder
907-
self.data.folders.retain(|f| f.id != folder_id);
905+
// Remove folders
906+
self.data.folders.retain(|f| !f.id.starts_with(&prefix));
908907

909908
// Remove from project_order
910-
self.data.project_order.retain(|id| *id != folder_id && !id.starts_with(&prefix));
909+
self.data.project_order.retain(|id| !id.starts_with(&prefix));
911910

912911
// Remove from project_widths
913912
self.data.project_widths.retain(|id, _| !id.starts_with(&prefix));
@@ -3613,17 +3612,17 @@ mod gpui_tests {
36133612

36143613
let mut data = make_workspace_data(
36153614
vec![local, remote1, remote2, remote3],
3616-
vec!["local1", "remote-folder:conn1", "remote-folder:conn2"],
3615+
vec!["local1", "remote:conn1:folder1", "remote:conn2:folder2"],
36173616
);
36183617
data.folders.push(FolderData {
3619-
id: "remote-folder:conn1".to_string(),
3618+
id: "remote:conn1:folder1".to_string(),
36203619
name: "Server 1".to_string(),
36213620
project_ids: vec!["remote:conn1:p1".to_string(), "remote:conn1:p2".to_string()],
36223621
collapsed: false,
36233622
folder_color: FolderColor::default(),
36243623
});
36253624
data.folders.push(FolderData {
3626-
id: "remote-folder:conn2".to_string(),
3625+
id: "remote:conn2:folder2".to_string(),
36273626
name: "Server 2".to_string(),
36283627
project_ids: vec!["remote:conn2:p1".to_string()],
36293628
collapsed: false,
@@ -3646,11 +3645,11 @@ mod gpui_tests {
36463645

36473646
// conn1 folder removed, conn2 folder remains
36483647
assert_eq!(ws.data.folders.len(), 1);
3649-
assert_eq!(ws.data.folders[0].id, "remote-folder:conn2");
3648+
assert_eq!(ws.data.folders[0].id, "remote:conn2:folder2");
36503649

36513650
// project_order cleaned
3652-
assert!(!ws.data.project_order.contains(&"remote-folder:conn1".to_string()));
3653-
assert!(ws.data.project_order.contains(&"remote-folder:conn2".to_string()));
3651+
assert!(!ws.data.project_order.contains(&"remote:conn1:folder1".to_string()));
3652+
assert!(ws.data.project_order.contains(&"remote:conn2:folder2".to_string()));
36543653
});
36553654
}
36563655

@@ -3666,10 +3665,10 @@ mod gpui_tests {
36663665

36673666
let mut data = make_workspace_data(
36683667
vec![local, remote1, remote2],
3669-
vec!["local1", "remote-folder:conn1"],
3668+
vec!["local1", "remote:conn1:folder1"],
36703669
);
36713670
data.folders.push(FolderData {
3672-
id: "remote-folder:conn1".to_string(),
3671+
id: "remote:conn1:folder1".to_string(),
36733672
name: "Server 1".to_string(),
36743673
project_ids: vec!["remote:conn1:p1".to_string(), "remote:conn1:p2".to_string()],
36753674
collapsed: false,

src/app/remote_commands.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ pub(crate) async fn remote_command_loop(
163163
git_status,
164164
folder_color: p.folder_color,
165165
services,
166+
worktree_info: p.worktree_info.as_ref().map(|wt| {
167+
okena_core::api::ApiWorktreeMetadata {
168+
parent_project_id: wt.parent_project_id.clone(),
169+
color_override: wt.color_override,
170+
}
171+
}),
172+
worktree_ids: p.worktree_ids.clone(),
166173
}
167174
};
168175

src/views/root/mod.rs

Lines changed: 75 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -397,44 +397,42 @@ impl RootView {
397397

398398
for snap in &snapshots {
399399
let conn_id = &snap.config.id;
400-
let folder_id = format!("remote-folder:{}", conn_id);
401400

402401
if let Some(ref state) = snap.state {
403-
// Build folder_project_ids using server's order when available
404-
let folder_project_ids: Vec<String> = if !state.project_order.is_empty() {
405-
// New server: walk project_order, expand folder entries via state.folders
406-
let server_folder_map: std::collections::HashMap<&str, &okena_core::api::ApiFolder> =
407-
state.folders.iter().map(|f| (f.id.as_str(), f)).collect();
408-
let mut ordered = Vec::new();
409-
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
402+
// Build the server folder lookup
403+
let server_folder_map: std::collections::HashMap<&str, &okena_core::api::ApiFolder> =
404+
state.folders.iter().map(|f| (f.id.as_str(), f)).collect();
405+
406+
// Build prefixed project_order and folder entries that mirror the server structure
407+
let mut remote_order: Vec<String> = Vec::new();
408+
let mut remote_folders: Vec<FolderData> = Vec::new();
409+
410+
if !state.project_order.is_empty() {
410411
for order_id in &state.project_order {
411412
if let Some(sf) = server_folder_map.get(order_id.as_str()) {
412-
for pid in &sf.project_ids {
413-
let prefixed = format!("remote:{}:{}", conn_id, pid);
414-
if seen_ids.insert(prefixed.clone()) {
415-
ordered.push(prefixed);
416-
}
417-
}
413+
// This is a folder — create a prefixed FolderData
414+
let prefixed_folder_id = format!("remote:{}:{}", conn_id, sf.id);
415+
let prefixed_project_ids: Vec<String> = sf.project_ids.iter()
416+
.map(|pid| format!("remote:{}:{}", conn_id, pid))
417+
.collect();
418+
remote_folders.push(FolderData {
419+
id: prefixed_folder_id.clone(),
420+
name: sf.name.clone(),
421+
project_ids: prefixed_project_ids,
422+
collapsed: false,
423+
folder_color: sf.folder_color,
424+
});
425+
remote_order.push(prefixed_folder_id);
418426
} else {
419-
let prefixed = format!("remote:{}:{}", conn_id, order_id);
420-
if seen_ids.insert(prefixed.clone()) {
421-
ordered.push(prefixed);
422-
}
427+
// This is a top-level project
428+
remote_order.push(format!("remote:{}:{}", conn_id, order_id));
423429
}
424430
}
425-
// Append orphans not in order
431+
} else {
432+
// Old server without project_order: put all projects as top-level
426433
for api_project in &state.projects {
427-
let prefixed = format!("remote:{}:{}", conn_id, api_project.id);
428-
if seen_ids.insert(prefixed.clone()) {
429-
ordered.push(prefixed);
430-
}
434+
remote_order.push(format!("remote:{}:{}", conn_id, api_project.id));
431435
}
432-
ordered
433-
} else {
434-
// Old server: fall back to state.projects Vec order
435-
state.projects.iter()
436-
.map(|p| format!("remote:{}:{}", conn_id, p.id))
437-
.collect()
438436
};
439437

440438
for api_project in &state.projects {
@@ -478,9 +476,33 @@ impl RootView {
478476
existing.remote_services = remote_services;
479477
existing.remote_host = remote_host;
480478
existing.remote_git_status = api_project.git_status.clone();
479+
existing.worktree_info = api_project.worktree_info.as_ref().map(|wt| {
480+
crate::workspace::state::WorktreeMetadata {
481+
parent_project_id: format!("remote:{}:{}", conn_id, wt.parent_project_id),
482+
color_override: wt.color_override,
483+
main_repo_path: String::new(),
484+
worktree_path: String::new(),
485+
branch_name: String::new(),
486+
}
487+
});
488+
existing.worktree_ids = api_project.worktree_ids.iter()
489+
.map(|id| format!("remote:{}:{}", conn_id, id))
490+
.collect();
481491
// Don't overwrite show_in_overview — it's client-side state
482492
// (the user may have toggled visibility locally).
483493
} else {
494+
let worktree_info = api_project.worktree_info.as_ref().map(|wt| {
495+
crate::workspace::state::WorktreeMetadata {
496+
parent_project_id: format!("remote:{}:{}", conn_id, wt.parent_project_id),
497+
color_override: wt.color_override,
498+
main_repo_path: String::new(),
499+
worktree_path: String::new(),
500+
branch_name: String::new(),
501+
}
502+
});
503+
let worktree_ids: Vec<String> = api_project.worktree_ids.iter()
504+
.map(|id| format!("remote:{}:{}", conn_id, id))
505+
.collect();
484506
ws.data.projects.push(ProjectData {
485507
id: prefixed_id.clone(),
486508
name: api_project.name.clone(),
@@ -489,8 +511,8 @@ impl RootView {
489511
layout,
490512
terminal_names,
491513
hidden_terminals: std::collections::HashMap::new(),
492-
worktree_info: None,
493-
worktree_ids: Vec::new(),
514+
worktree_info,
515+
worktree_ids,
494516
folder_color: project_color,
495517
hooks: HooksConfig::default(),
496518
is_remote: true,
@@ -506,32 +528,30 @@ impl RootView {
506528
});
507529
}
508530

509-
let folder_name = snap.config.name.clone();
531+
// Sync remote folders and project_order into workspace
532+
let remote_prefix = format!("remote:{}:", conn_id);
510533
workspace.update(cx, |ws, _cx| {
511-
if let Some(folder) = ws.data.folders.iter_mut().find(|f| f.id == folder_id) {
512-
folder.name = folder_name;
513-
folder.project_ids = folder_project_ids;
514-
} else {
515-
ws.data.folders.push(FolderData {
516-
id: folder_id.clone(),
517-
name: folder_name,
518-
project_ids: folder_project_ids,
519-
collapsed: false,
520-
folder_color: FolderColor::default(),
521-
});
522-
}
523-
if !ws.data.project_order.contains(&folder_id) {
524-
ws.data.project_order.push(folder_id.clone());
534+
// Remove old remote folders for this connection
535+
ws.data.folders.retain(|f| !f.id.starts_with(&remote_prefix));
536+
// Remove old remote entries from project_order for this connection
537+
ws.data.project_order.retain(|id| !id.starts_with(&remote_prefix));
538+
539+
// Add new remote folders
540+
for rf in remote_folders {
541+
// Preserve collapsed state from previous sync
542+
ws.data.folders.push(rf);
525543
}
544+
545+
// Add new remote project_order entries
546+
ws.data.project_order.extend(remote_order);
526547
});
527548
} else {
528-
// No state (disconnected/connecting) — remove materialized projects
549+
// No state (disconnected/connecting) — remove materialized projects and folders
529550
let prefix = format!("remote:{}:", conn_id);
530551
workspace.update(cx, |ws, _cx| {
531552
ws.data.projects.retain(|p| !p.id.starts_with(&prefix));
532-
if let Some(folder) = ws.data.folders.iter_mut().find(|f| f.id == folder_id) {
533-
folder.project_ids.clear();
534-
}
553+
ws.data.folders.retain(|f| !f.id.starts_with(&prefix));
554+
ws.data.project_order.retain(|id| !id.starts_with(&prefix));
535555
});
536556
}
537557
}
@@ -546,8 +566,11 @@ impl RootView {
546566
}
547567
});
548568
ws.data.folders.retain(|f| {
549-
if f.id.starts_with("remote-folder:") {
550-
let conn_id = f.id.strip_prefix("remote-folder:").unwrap_or("");
569+
if f.id.starts_with("remote:") {
570+
// Remote folder IDs are "remote:{conn_id}:{folder_id}"
571+
// Extract conn_id (second segment)
572+
let rest = f.id.strip_prefix("remote:").unwrap_or("");
573+
let conn_id = rest.split(':').next().unwrap_or("");
551574
active_conn_ids.contains(conn_id)
552575
} else {
553576
true

0 commit comments

Comments
 (0)