mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 00:15:06 +00:00
feat(sidebar): draw pinned groups above a divider, with their own gestures
The sidebar now reads as two halves: the groups you keep, in the order you put them, then a divider, then the groups it works out (Arc-style). - Pinned headers drag-reorder among themselves (their own reorder surface, so a pinned header cannot be dropped among the derived ones); the order lands on the workspace's group list, not on the tabs. - An auto header carried above the divider is pinned when let go. With nothing pinned yet the divider appears during that drag as a "Drop here to pin" zone, since a hairline at the top of the list is nothing to aim at. - A tab kept in a pinned group and dropped anywhere below the divider goes back to auto grouping; the divider lights to say so. - An empty pinned group stays, with a "+ New Tab" row that opens a tab in its folder (or where ⌘T would, for a label group) and files it there. - Folder groups carry a pin mark that unpins on click and a tooltip with the folder; auto headers show pin and "+" on hover. - Header menus: pinned — Rename, Set Folder… (local workspaces), Use Current Tab's Folder, Clear Folder, New Tab, Unpin (folder groups), Delete. Auto — Pin Group, New Tab. Nothing renames an auto group; nothing pins implicitly.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>
|
||||
|
After Width: | Height: | Size: 424 B |
@@ -34,6 +34,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
|
||||
"icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"),
|
||||
"icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"),
|
||||
"icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"),
|
||||
"icons/pin.svg" => include_bytes!("../../assets/icons/pin.svg"),
|
||||
"icons/ellipsis.svg" => include_bytes!("../../assets/icons/ellipsis.svg"),
|
||||
"icons/folder-closed.svg" => include_bytes!("../../assets/icons/folder-closed.svg"),
|
||||
"icons/folder-open.svg" => include_bytes!("../../assets/icons/folder-open.svg"),
|
||||
|
||||
@@ -547,6 +547,26 @@ mod tests {
|
||||
assert_eq!(take_landed(&state).pin, Some(key));
|
||||
}
|
||||
|
||||
/// A finished drag says which surface its order is for: the pinned
|
||||
/// headers' order is the list's, not the tabs'.
|
||||
#[test]
|
||||
fn a_landing_names_the_surface_its_order_is_for() {
|
||||
let r = Reorder::new(
|
||||
Surface::PinnedGroups,
|
||||
0,
|
||||
column(3, 30., 2., 0).rects,
|
||||
Axis::Vertical,
|
||||
px(2.),
|
||||
point(px(0.), px(0.)),
|
||||
);
|
||||
let state: ReorderState = Rc::new(RefCell::new(Some(r)));
|
||||
clear_pending(&state);
|
||||
set_pending(&state, &Surface::PinnedGroups, vec![1, 0, 2]);
|
||||
let landed = take_landed(&state);
|
||||
assert_eq!(landed.surface, Some(Surface::PinnedGroups));
|
||||
assert_eq!(landed.order, Some(vec![1, 0, 2]));
|
||||
}
|
||||
|
||||
/// Only an auto header can be pinned by dragging; a drag carrying
|
||||
/// anything else offers nothing.
|
||||
#[test]
|
||||
|
||||
+596
-51
@@ -232,6 +232,9 @@ impl Tty7App {
|
||||
// same way a pane dropped here is. Blanked and written again below
|
||||
// so a group that folds or filters away stops accepting drops.
|
||||
let over_group = self.sidebar_regroup_target(window);
|
||||
// Taken rather than read, for the same reason: last frame's divider is
|
||||
// what a drag is measured against, and the one drawn below replaces it.
|
||||
let divider_last = self.sidebar_divider.take();
|
||||
let lifting_row = crate::ui::reorder::dragged_sidebar_tab(&self.reorder).is_some();
|
||||
// Offered every frame the pointer is over a group, and cleared with
|
||||
// the rest of the drag's pending state on the frames it is not — so
|
||||
@@ -343,12 +346,45 @@ impl Tty7App {
|
||||
cx.theme().muted_foreground,
|
||||
cx.theme().sidebar,
|
||||
);
|
||||
let rendered = |ix: &usize| !visible_by_section[*ix].is_empty();
|
||||
// A pinned group is drawn with no tabs in it — it is kept, and the
|
||||
// row it grows says how to fill it — except while a search is
|
||||
// narrowing the list, where a group with nothing matching is noise.
|
||||
let rendered = |ix: &usize| {
|
||||
!visible_by_section[*ix].is_empty()
|
||||
|| (query.is_empty() && sections[*ix].pinned().is_some())
|
||||
};
|
||||
// Pinned headers reorder among themselves, by hand; that order is the
|
||||
// workspace's and is written back whole when a drag lands.
|
||||
let pinned_slots: Vec<usize> = (0..sections.len())
|
||||
.filter(|&ix| sections[ix].pinned().is_some())
|
||||
.filter(rendered)
|
||||
.collect();
|
||||
let pinned_rects: Rc<RefCell<Vec<Bounds<Pixels>>>> =
|
||||
Rc::new(RefCell::new(vec![Bounds::default(); pinned_slots.len()]));
|
||||
let pinned_preview = reorder::preview(
|
||||
&self.reorder,
|
||||
&Surface::PinnedGroups,
|
||||
pinned_slots.len(),
|
||||
pointer,
|
||||
);
|
||||
let pinned_display: Vec<usize> = match &pinned_preview {
|
||||
Some(p) => {
|
||||
let all: Vec<GroupId> = groups.pinned.iter().map(|g| g.id).collect();
|
||||
let shown: Vec<GroupId> = pinned_slots
|
||||
.iter()
|
||||
.filter_map(|&ix| sections[ix].pinned())
|
||||
.collect();
|
||||
if let Some(order) = reordered_pinned(&all, &shown, p.from, p.target) {
|
||||
reorder::set_pending(&self.reorder, &Surface::PinnedGroups, order);
|
||||
}
|
||||
p.order.clone()
|
||||
}
|
||||
None => (0..pinned_slots.len()).collect(),
|
||||
};
|
||||
// Every auto group draws a header, and a header is what there is to
|
||||
// grab, so these are the slots the group-reorder surface runs over.
|
||||
// grab, so these are the slots the auto-group surface runs over.
|
||||
// Ungrouped is excluded: it is where the keyless tabs fall, and it
|
||||
// always sits last. Pinned groups sit above all of them, in an order
|
||||
// of their own.
|
||||
// always sits last.
|
||||
let keyed_slots: Vec<usize> = (0..sections.len())
|
||||
.filter(|&ix| {
|
||||
sections[ix]
|
||||
@@ -382,15 +418,15 @@ impl Tty7App {
|
||||
}
|
||||
None => (0..keyed_groups).collect(),
|
||||
};
|
||||
let mut blocks: Vec<(Option<usize>, usize)> = (0..sections.len())
|
||||
.filter(|&ix| sections[ix].pinned().is_some())
|
||||
.filter(rendered)
|
||||
.map(|ix| (None, ix))
|
||||
let mut blocks: Vec<(Option<HeaderSlot>, usize)> = pinned_display
|
||||
.into_iter()
|
||||
.map(|slot| (Some(HeaderSlot::Pinned(slot)), pinned_slots[slot]))
|
||||
.collect();
|
||||
let first_unpinned = blocks.len();
|
||||
blocks.extend(
|
||||
slot_display
|
||||
.into_iter()
|
||||
.map(|slot| (Some(slot), keyed_slots[slot])),
|
||||
.map(|slot| (Some(HeaderSlot::Auto(slot)), keyed_slots[slot])),
|
||||
);
|
||||
blocks.extend(
|
||||
(0..sections.len())
|
||||
@@ -398,8 +434,38 @@ impl Tty7App {
|
||||
.filter(rendered)
|
||||
.map(|ix| (None, ix)),
|
||||
);
|
||||
// The divider: kept groups above it, derived ones below. Drawn
|
||||
// whenever something is kept, and while an auto header is in the air
|
||||
// even when nothing is yet — carrying it above the divider is how it
|
||||
// gets pinned, so there has to be a divider to carry it above.
|
||||
let lifted_auto = reorder::dragged_auto_group(&self.reorder);
|
||||
let show_divider = !groups.pinned.is_empty() || lifted_auto.is_some();
|
||||
let pin_hover = lifted_auto.is_some()
|
||||
&& divider_last.is_some_and(|d| pointer.y < d.origin.y + d.size.height);
|
||||
if pin_hover {
|
||||
reorder::set_pin(&self.reorder);
|
||||
}
|
||||
let divider_lit = pin_hover || over_group == Some(reorder::Regroup::ToAuto);
|
||||
let divider_zone = groups.pinned.is_empty();
|
||||
let mut divider_drawn = false;
|
||||
// Whether the tab in the air is kept in a pinned group — then
|
||||
// everything below the divider is somewhere it can go.
|
||||
let lifted_is_kept = reorder::dragged_sidebar_tab(&self.reorder).is_some_and(|id| {
|
||||
self.tabs
|
||||
.iter()
|
||||
.find(|t| t.tree_id.get() == id)
|
||||
.and_then(|t| t.group.get())
|
||||
.is_some_and(|g| groups.contains(g))
|
||||
});
|
||||
let workspace_is_local = !crate::core::session::WorkspaceStore::all(cx)
|
||||
.get(self.workspace)
|
||||
.is_some_and(|w| w.is_remote());
|
||||
|
||||
for (group_slot, group_ix) in blocks {
|
||||
for (n, (group_slot, group_ix)) in blocks.into_iter().enumerate() {
|
||||
if n == first_unpinned && show_divider {
|
||||
list = list.child(self.sidebar_divider(divider_lit, divider_zone, cx));
|
||||
divider_drawn = true;
|
||||
}
|
||||
let section = §ions[group_ix];
|
||||
let group_key = section.key.clone();
|
||||
// Only a group that draws a header can be folded — there is
|
||||
@@ -1026,7 +1092,8 @@ impl Tty7App {
|
||||
}));
|
||||
}
|
||||
|
||||
if row_count == 0 {
|
||||
// An empty pinned group is still drawn: it is kept until deleted.
|
||||
if row_count == 0 && section.pinned().is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1087,6 +1154,11 @@ impl Tty7App {
|
||||
// header identical to that repo's.
|
||||
let pinned_id = section.pinned();
|
||||
let pinned = pinned_id.is_some();
|
||||
let pinned_folder: Option<SharedString> = pinned_id
|
||||
.and_then(|id| groups.get(id))
|
||||
.and_then(|g| g.folder.clone())
|
||||
.map(SharedString::from);
|
||||
let auto_key_here = group_key.as_ref().and_then(GroupKey::auto).cloned();
|
||||
let renaming_group = self
|
||||
.group_rename
|
||||
.as_ref()
|
||||
@@ -1106,7 +1178,7 @@ impl Tty7App {
|
||||
// row already elides its own.
|
||||
let ts = window.text_system();
|
||||
let mut avail = row_metrics::header_budget(width);
|
||||
if pinned {
|
||||
if pinned_folder.is_some() {
|
||||
avail -= row_metrics::HEADER_ICON + row_metrics::META_GAP;
|
||||
}
|
||||
let count_label = row_count.to_string();
|
||||
@@ -1129,8 +1201,11 @@ impl Tty7App {
|
||||
let name_avail = header_name_avail(avail, git_want);
|
||||
let label = elide_label(&ts, &header_font, header_size, &name, name_avail);
|
||||
let name_w = measure_text(&ts, &header_font, header_size, &label);
|
||||
let hover_group = SharedString::from(format!("sidebar-group-{group_ix}"));
|
||||
let bar = h_flex()
|
||||
.id(("sidebar-group", group_ix))
|
||||
.group(hover_group.clone())
|
||||
.relative()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
@@ -1149,19 +1224,31 @@ impl Tty7App {
|
||||
move |this, _, _window, cx| this.toggle_sidebar_group(key.as_ref(), cx)
|
||||
}))
|
||||
.when_some(group_slot, |header, slot| {
|
||||
let (surface, slot, slots) = match slot {
|
||||
HeaderSlot::Pinned(s) => {
|
||||
(Surface::PinnedGroups, s, pinned_rects.clone())
|
||||
}
|
||||
HeaderSlot::Auto(s) => (Surface::SidebarGroups, s, group_slots.clone()),
|
||||
};
|
||||
let auto = auto_key_here.clone();
|
||||
crate::ui::reorder::cursor_grab(header).on_drag(DragGroup, {
|
||||
let state = self.reorder.clone();
|
||||
let slots = group_slots.clone();
|
||||
move |_drag, grab, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
*state.borrow_mut() = Some(Reorder::new(
|
||||
Surface::SidebarGroups,
|
||||
let reorder = Reorder::new(
|
||||
surface.clone(),
|
||||
slot,
|
||||
slots.borrow().clone(),
|
||||
Axis::Vertical,
|
||||
px(ROW_GAP),
|
||||
grab,
|
||||
));
|
||||
);
|
||||
// An auto header says which group it is, so
|
||||
// letting it go above the divider can pin it.
|
||||
*state.borrow_mut() = Some(match auto.clone() {
|
||||
Some(key) => reorder.of_auto(key),
|
||||
None => reorder,
|
||||
});
|
||||
cx.new(|_| DragGroup)
|
||||
}
|
||||
})
|
||||
@@ -1176,13 +1263,37 @@ impl Tty7App {
|
||||
.child(Icon::new(IconName::ChevronRight).xsmall()),
|
||||
)
|
||||
})
|
||||
.when(pinned, |header| {
|
||||
header.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.child(Icon::new(IconName::Asterisk).xsmall()),
|
||||
)
|
||||
})
|
||||
// The pin marks a folder group: kept, and keeping a folder.
|
||||
// It is also the way to stop keeping it — a click unpins,
|
||||
// and the folder's tabs fall back to the groups their cwds
|
||||
// resolve to. A label group has no such mark: it sits above
|
||||
// the divider, and there is no folder to fall back on.
|
||||
.when_some(
|
||||
pinned_id.filter(|_| pinned_folder.is_some()),
|
||||
|header, id| {
|
||||
header.child(
|
||||
div()
|
||||
.id(("sidebar-group-unpin", group_ix))
|
||||
.flex_shrink_0()
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.text_color(cx.theme().foreground))
|
||||
.child(Icon::empty().path("icons/pin.svg").xsmall())
|
||||
.tooltip(|window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(t(
|
||||
L10nKey::SidebarUnpinGroup,
|
||||
))
|
||||
.build(window, cx)
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
||||
cx.stop_propagation()
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.delete_group(id, cx);
|
||||
})),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(match renaming_group {
|
||||
Some(input) => div()
|
||||
.id(("sidebar-group-rename", group_ix))
|
||||
@@ -1200,6 +1311,7 @@ impl Tty7App {
|
||||
// backstop for a face that measures wider than it
|
||||
// paints; the name no longer gives room to the branch.
|
||||
None => div()
|
||||
.id(("sidebar-group-name", group_ix))
|
||||
.flex_shrink_0()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
@@ -1209,6 +1321,14 @@ impl Tty7App {
|
||||
// counts beside it are the metadata.
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(label)
|
||||
// A folder group may have been renamed to anything;
|
||||
// the folder it keeps is what the name stands for.
|
||||
.when_some(pinned_folder.clone(), |name, folder| {
|
||||
name.tooltip(move |window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(folder.clone())
|
||||
.build(window, cx)
|
||||
})
|
||||
})
|
||||
.into_any_element(),
|
||||
})
|
||||
.when_some(shared_git, |bar, shared| {
|
||||
@@ -1298,32 +1418,41 @@ impl Tty7App {
|
||||
// is what a shut group has instead of them.
|
||||
.when(folded, |bar| {
|
||||
bar.child(div().flex_shrink_0().child(count_label))
|
||||
});
|
||||
})
|
||||
.children(self.header_actions(group_ix, group_key.clone(), hover_group, cx));
|
||||
// Renaming is offered on a menu rather than a double click:
|
||||
// the first click of a double would fold the group, so the
|
||||
// name would be edited on a box that just shut. A repo group
|
||||
// gets no menu — it is named after its root, and a rename
|
||||
// there could only lie about where its tabs are.
|
||||
// name would be edited on a box that just shut. An auto group
|
||||
// offers no rename — it is named after its repo or host, and a
|
||||
// rename there could only lie about where its tabs are; pin it
|
||||
// first, and it is the user's to name.
|
||||
//
|
||||
// Attached last and erased to `AnyElement`, because the menu
|
||||
// wrapper changes the element's type and the two arms have to
|
||||
// wrapper changes the element's type and the arms have to
|
||||
// agree.
|
||||
match pinned_id {
|
||||
Some(id) => {
|
||||
let app = cx.entity().downgrade();
|
||||
bar.context_menu(move |menu, _window, _cx| {
|
||||
let app = app.clone();
|
||||
menu.item(PopupMenuItem::new(t(L10nKey::SidebarRenameGroup)).on_click(
|
||||
move |_, window, cx| {
|
||||
let _ = app.update(cx, |this, cx| {
|
||||
this.start_group_rename(id, window, cx)
|
||||
});
|
||||
},
|
||||
))
|
||||
let app = cx.entity().downgrade();
|
||||
match (pinned_id, auto_key_here.clone()) {
|
||||
(Some(id), _) => {
|
||||
let has_folder = pinned_folder.is_some();
|
||||
bar.context_menu(move |menu, window, cx| {
|
||||
pinned_header_menu(
|
||||
menu,
|
||||
id,
|
||||
has_folder,
|
||||
workspace_is_local,
|
||||
&app,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
None => bar.into_any_element(),
|
||||
(None, Some(key)) => bar
|
||||
.context_menu(move |menu, _window, _cx| {
|
||||
auto_header_menu(menu, key.clone(), &app)
|
||||
})
|
||||
.into_any_element(),
|
||||
(None, None) => bar.into_any_element(),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1332,16 +1461,53 @@ impl Tty7App {
|
||||
// that cannot fade back. Until a drag is under way they look
|
||||
// alike, and this is where a user finds out which is which
|
||||
// without being told.
|
||||
let takes_drops = pinned;
|
||||
let takes_drops = pinned || (lifted_is_kept && n >= first_unpinned);
|
||||
let (preview, slot) = match group_slot {
|
||||
Some(HeaderSlot::Pinned(s)) => (pinned_preview.as_ref(), Some(s)),
|
||||
Some(HeaderSlot::Auto(s)) => (group_preview.as_ref(), Some(s)),
|
||||
None => (None, None),
|
||||
};
|
||||
// An empty pinned group says how to fill it, in the place its
|
||||
// rows would be: a tab can be dragged onto it, or opened here.
|
||||
let empty_row = (row_count == 0 && !folded)
|
||||
.then_some(pinned_id)
|
||||
.flatten()
|
||||
.map(|id| {
|
||||
h_flex()
|
||||
.id(("sidebar-group-empty", group_ix))
|
||||
.w_full()
|
||||
.h(px(ROW_HEIGHT))
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.pl_2()
|
||||
.rounded(crate::ui::rounding::CARD_RADIUS)
|
||||
.cursor_pointer()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.hover(|s| {
|
||||
s.bg(gpui::rgb(sf.hover))
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(row_metrics::AVATAR))
|
||||
.child(Icon::empty().path("icons/plus.svg").xsmall()),
|
||||
)
|
||||
.child(t(L10nKey::SidebarGroupNewTab))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.new_tab_in_group(GroupKey::Pinned(id), window, cx)
|
||||
}))
|
||||
});
|
||||
let block = v_flex()
|
||||
.w_full()
|
||||
.gap(px(ROW_GAP))
|
||||
.when(
|
||||
group_preview
|
||||
.as_ref()
|
||||
.is_some_and(|p| Some(p.from) == group_slot),
|
||||
|b| b.opacity(0.75),
|
||||
)
|
||||
.when(preview.is_some_and(|p| Some(p.from) == slot), |b| {
|
||||
b.opacity(0.75)
|
||||
})
|
||||
.when(lifting_row && !takes_drops, |b| b.opacity(0.4))
|
||||
.when(
|
||||
pinned_id.is_some_and(|id| over_group == Some(reorder::Regroup::Into(id))),
|
||||
@@ -1349,16 +1515,20 @@ impl Tty7App {
|
||||
)
|
||||
.children(header)
|
||||
.children(rows)
|
||||
.children(empty_row)
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let slots = group_slots.clone();
|
||||
let slots = match group_slot {
|
||||
Some(HeaderSlot::Pinned(_)) => pinned_rects.clone(),
|
||||
_ => group_slots.clone(),
|
||||
};
|
||||
let landing = self.sidebar_group_slots.clone();
|
||||
// Only a pinned group is recorded, so a drag
|
||||
// looking for somewhere to land finds nothing over
|
||||
// an auto group or over Ungrouped.
|
||||
move |bounds, _window, _cx| {
|
||||
if let Some(slot) = group_slot
|
||||
if let Some(slot) = slot
|
||||
&& let Some(s) = slots.borrow_mut().get_mut(slot)
|
||||
{
|
||||
*s = bounds;
|
||||
@@ -1375,7 +1545,7 @@ impl Tty7App {
|
||||
);
|
||||
|
||||
any_rows = true;
|
||||
list = list.child(match (&group_preview, group_slot) {
|
||||
list = list.child(match (preview, slot) {
|
||||
(Some(p), Some(slot)) if p.from == slot => {
|
||||
deferred(block.relative().top(p.held)).into_any_element()
|
||||
}
|
||||
@@ -1396,6 +1566,9 @@ impl Tty7App {
|
||||
_ => block.into_any_element(),
|
||||
});
|
||||
}
|
||||
if show_divider && !divider_drawn {
|
||||
list = list.child(self.sidebar_divider(divider_lit, divider_zone, cx));
|
||||
}
|
||||
|
||||
if !any_rows && !query.is_empty() {
|
||||
list = list.child(
|
||||
@@ -1630,6 +1803,142 @@ impl Tty7App {
|
||||
.child(handle)
|
||||
}
|
||||
|
||||
/// The line between the kept groups and the derived ones, recording where
|
||||
/// it was drawn so a drag next frame can tell which side it is on.
|
||||
///
|
||||
/// With nothing pinned yet it only appears while an auto header is being
|
||||
/// carried, and then as a drop zone rather than a hairline: a one-pixel
|
||||
/// line at the very top of the list is not something anyone could aim
|
||||
/// above. `lit` while letting go would pin the header, or hand a kept tab
|
||||
/// back to auto grouping.
|
||||
fn sidebar_divider(&self, lit: bool, zone: bool, cx: &Context<Self>) -> AnyElement {
|
||||
let bounds = self.sidebar_divider.clone();
|
||||
let ink = match lit {
|
||||
true => cx.theme().drag_border,
|
||||
false => cx.theme().sidebar_border,
|
||||
};
|
||||
let body = match zone {
|
||||
true => h_flex()
|
||||
.h(px(ROW_HEIGHT))
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(crate::ui::rounding::CARD_RADIUS)
|
||||
.border_1()
|
||||
.border_color(ink)
|
||||
.when(lit, |d| d.bg(cx.theme().drag_border.opacity(0.15)))
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(t(L10nKey::SidebarDropToPin))
|
||||
.into_any_element(),
|
||||
false => div()
|
||||
.w_full()
|
||||
.h(px(if lit { 2. } else { 1. }))
|
||||
.bg(ink)
|
||||
.into_any_element(),
|
||||
};
|
||||
v_flex()
|
||||
.id("sidebar-divider")
|
||||
.relative()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.pt(px(10.))
|
||||
.pb(px(2.))
|
||||
.child(
|
||||
canvas(move |b, _window, _cx| bounds.set(Some(b)), |_, _, _, _| {})
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
.child(body)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The buttons a header shows on hover, laid over its right end: pin for
|
||||
/// an auto group, and "+" to open a tab in the group. `None` for
|
||||
/// Ungrouped, which has neither.
|
||||
///
|
||||
/// Laid over the branch rather than beside it, the way a row's close
|
||||
/// button covers its badge, so a header does not give up width it would
|
||||
/// only use while the pointer is on it. Acted on at the press, as a row's
|
||||
/// diff counts are: the header folds on click and picks itself up on a
|
||||
/// drag, and swallowing the press is what keeps both from happening too.
|
||||
fn header_actions(
|
||||
&self,
|
||||
group_ix: usize,
|
||||
key: Option<GroupKey>,
|
||||
hover: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
let key = key?;
|
||||
let backing = crate::ui::theme::workspace_surface_color(cx);
|
||||
let mut fade = backing;
|
||||
fade.a = 0.;
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let ink = cx.theme().foreground;
|
||||
let button = |id: &'static str, path: &'static str, tip: L10nKey| {
|
||||
div()
|
||||
.id((id, group_ix))
|
||||
.flex_shrink_0()
|
||||
.p(px(2.))
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.text_color(muted)
|
||||
.hover(move |s| s.text_color(ink))
|
||||
.child(Icon::empty().path(path).xsmall())
|
||||
.tooltip(move |window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(t(tip)).build(window, cx)
|
||||
})
|
||||
};
|
||||
let mut buttons = h_flex().gap_0p5().bg(backing);
|
||||
if let Some(auto) = key.auto().cloned() {
|
||||
buttons = buttons.child(
|
||||
button(
|
||||
"sidebar-group-pin",
|
||||
"icons/pin.svg",
|
||||
L10nKey::SidebarPinGroup,
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _: &MouseDownEvent, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.pin_auto_group(auto.clone(), cx);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
if !matches!(key, GroupKey::Auto(AutoKey::SshHost(_))) {
|
||||
buttons = buttons.child(
|
||||
button(
|
||||
"sidebar-group-add",
|
||||
"icons/plus.svg",
|
||||
L10nKey::SidebarGroupNewTab,
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _: &MouseDownEvent, window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.new_tab_in_group(key.clone(), window, cx);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(
|
||||
h_flex()
|
||||
.absolute()
|
||||
.right(px(4.))
|
||||
.bottom(px(2.))
|
||||
.opacity(0.)
|
||||
.group_hover(hover, |s| s.opacity(1.))
|
||||
.child(div().w(px(10.)).h(px(16.)).bg(linear_gradient(
|
||||
90.,
|
||||
linear_color_stop(fade, 0.),
|
||||
linear_color_stop(backing, 1.),
|
||||
)))
|
||||
.child(buttons)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// What the sidebar row hid: the full title, the full branch and diff
|
||||
/// counts, the working directory, and the remote host the avatar only
|
||||
/// dots. `None` when the row showed everything — a card would add noise,
|
||||
@@ -2240,6 +2549,125 @@ impl Tty7App {
|
||||
}
|
||||
}
|
||||
|
||||
/// A header that can be picked up, and which of the two orders it belongs to.
|
||||
#[derive(Clone, Copy)]
|
||||
enum HeaderSlot {
|
||||
/// Slot `n` among the pinned headers, above the divider.
|
||||
Pinned(usize),
|
||||
/// Slot `n` among the auto headers, below it.
|
||||
Auto(usize),
|
||||
}
|
||||
|
||||
/// One item of a group header's menu, acting on the app when it is chosen.
|
||||
fn menu_item(
|
||||
label: L10nKey,
|
||||
app: &gpui::WeakEntity<Tty7App>,
|
||||
act: impl Fn(&mut Tty7App, &mut Window, &mut Context<Tty7App>) + 'static,
|
||||
) -> PopupMenuItem {
|
||||
let app = app.clone();
|
||||
PopupMenuItem::new(t(label)).on_click(move |_, window, cx| {
|
||||
let _ = app.update(cx, |this, cx| act(this, window, cx));
|
||||
})
|
||||
}
|
||||
|
||||
/// A pinned group's header menu. Everything about a kept group is the
|
||||
/// user's to change: its name, the folder it keeps, and whether it is kept at
|
||||
/// all. Deleting closes nothing — its tabs go back to auto grouping.
|
||||
///
|
||||
/// "Set Folder…" asks the system picker, which browses this computer, so it
|
||||
/// is only offered for a workspace on it; "Use Current Tab's Folder" works
|
||||
/// anywhere, since the tab's cwd is on the workspace's own host.
|
||||
fn pinned_header_menu(
|
||||
menu: gpui_component::menu::PopupMenu,
|
||||
id: GroupId,
|
||||
has_folder: bool,
|
||||
local: bool,
|
||||
app: &gpui::WeakEntity<Tty7App>,
|
||||
window: &Window,
|
||||
cx: &gpui::App,
|
||||
) -> gpui_component::menu::PopupMenu {
|
||||
let current = app
|
||||
.upgrade()
|
||||
.and_then(|a| a.read(cx).active_tab_folder(window, cx));
|
||||
let mut menu = menu.min_w(px(200.)).item(menu_item(
|
||||
L10nKey::SidebarRenameGroup,
|
||||
app,
|
||||
move |this, w, cx| this.start_group_rename(id, w, cx),
|
||||
));
|
||||
if local {
|
||||
menu = menu.item(menu_item(
|
||||
L10nKey::SidebarSetGroupFolder,
|
||||
app,
|
||||
move |this, _, cx| this.pick_group_folder(id, cx),
|
||||
));
|
||||
}
|
||||
menu = menu.item(
|
||||
menu_item(
|
||||
L10nKey::SidebarUseCurrentTabFolder,
|
||||
app,
|
||||
move |this, w, cx| {
|
||||
if let Some(folder) = this.active_tab_folder(w, cx) {
|
||||
this.set_group_folder(id, Some(folder), cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.disabled(current.is_none()),
|
||||
);
|
||||
if has_folder {
|
||||
menu = menu.item(menu_item(
|
||||
L10nKey::SidebarClearGroupFolder,
|
||||
app,
|
||||
move |this, _, cx| this.set_group_folder(id, None, cx),
|
||||
));
|
||||
}
|
||||
menu = menu
|
||||
.separator()
|
||||
.item(menu_item(
|
||||
L10nKey::SidebarGroupNewTab,
|
||||
app,
|
||||
move |this, w, cx| this.new_tab_in_group(GroupKey::Pinned(id), w, cx),
|
||||
))
|
||||
.separator();
|
||||
if has_folder {
|
||||
menu = menu.item(menu_item(
|
||||
L10nKey::SidebarUnpinGroup,
|
||||
app,
|
||||
move |this, _, cx| this.delete_group(id, cx),
|
||||
));
|
||||
}
|
||||
menu.item(menu_item(
|
||||
L10nKey::SidebarDeleteGroup,
|
||||
app,
|
||||
move |this, _, cx| this.delete_group(id, cx),
|
||||
))
|
||||
}
|
||||
|
||||
/// An auto group's header menu: pin it, or open a tab in it. Nothing else —
|
||||
/// an auto group is derived, so there is nothing about it to edit until it
|
||||
/// is pinned, and pinning is always an explicit act.
|
||||
fn auto_header_menu(
|
||||
menu: gpui_component::menu::PopupMenu,
|
||||
key: AutoKey,
|
||||
app: &gpui::WeakEntity<Tty7App>,
|
||||
) -> gpui_component::menu::PopupMenu {
|
||||
let pin = key.clone();
|
||||
let mut menu = menu.min_w(px(200.)).item(menu_item(
|
||||
L10nKey::SidebarPinGroup,
|
||||
app,
|
||||
move |this, _, cx| this.pin_auto_group(pin.clone(), cx),
|
||||
));
|
||||
// A host group has no directory to open a tab in, and no way to open a
|
||||
// shell on the host that would not guess at how the others got there.
|
||||
if let AutoKey::Repo(_) = &key {
|
||||
menu = menu.item(menu_item(
|
||||
L10nKey::SidebarGroupNewTab,
|
||||
app,
|
||||
move |this, w, cx| this.new_tab_in_group(GroupKey::Auto(key.clone()), w, cx),
|
||||
));
|
||||
}
|
||||
menu
|
||||
}
|
||||
|
||||
/// Where a tab about to be spawned goes, worked out before it exists — see
|
||||
/// [`Tty7App::spawn_group`].
|
||||
pub(crate) struct SpawnPlace {
|
||||
@@ -3268,6 +3696,123 @@ mod fold_tests {
|
||||
assert!((0..2).all(|i| drawn(app, i)), "unfolding brings both back");
|
||||
});
|
||||
}
|
||||
/// The divider stands between kept and derived groups, so it is there
|
||||
/// exactly when something is kept — and it is recorded, because a tab
|
||||
/// dropped below it leaves its pinned group.
|
||||
#[gpui::test]
|
||||
fn the_divider_is_drawn_once_something_is_pinned(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
|
||||
app.update(&mut vcx, |_, cx| cx.notify());
|
||||
vcx.run_until_parked();
|
||||
app.update(&mut vcx, |app, _| {
|
||||
assert!(app.sidebar_divider.get().is_none(), "nothing kept, no line");
|
||||
});
|
||||
|
||||
app.update(&mut vcx, |app, cx| {
|
||||
label(app, "work", cx);
|
||||
});
|
||||
vcx.run_until_parked();
|
||||
app.update(&mut vcx, |app, _| {
|
||||
assert!(app.sidebar_divider.get().is_some(), "kept, so a line");
|
||||
});
|
||||
}
|
||||
|
||||
/// An empty pinned group stays on screen, and takes drops like any other:
|
||||
/// its block is recorded where a dragged tab looks for somewhere to land.
|
||||
#[gpui::test]
|
||||
fn an_empty_pinned_group_is_drawn_and_takes_drops(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||||
|
||||
let work = app.update(&mut vcx, |app, cx| label(app, "work", cx));
|
||||
vcx.run_until_parked();
|
||||
|
||||
app.update(&mut vcx, |app, _| {
|
||||
let landing = app.sidebar_group_slots.borrow();
|
||||
let block = landing
|
||||
.iter()
|
||||
.find(|(id, _)| *id == work)
|
||||
.expect("the empty group was drawn");
|
||||
assert!(block.1.size.height > px(0.));
|
||||
});
|
||||
}
|
||||
|
||||
/// Pinned headers are ordered by hand; a drag lands as a new order for
|
||||
/// the list, not for the tabs.
|
||||
#[gpui::test]
|
||||
fn a_pinned_drag_reorders_the_groups_not_the_tabs(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 2);
|
||||
|
||||
app.update(&mut vcx, |app, cx| {
|
||||
let (a, b, c) = (
|
||||
label(app, "a", cx),
|
||||
label(app, "b", cx),
|
||||
label(app, "c", cx),
|
||||
);
|
||||
app.set_tab_group(0, Some(a), cx);
|
||||
app.set_tab_group(1, Some(c), cx);
|
||||
let tabs: Vec<_> = app.tabs.iter().map(|t| t.tree_id.get()).collect();
|
||||
app.apply_pinned_order(&[2, 0, 1], cx);
|
||||
assert_eq!(
|
||||
app.pinned_group_names()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![c, a, b]
|
||||
);
|
||||
assert_eq!(
|
||||
app.tabs.iter().map(|t| t.tree_id.get()).collect::<Vec<_>>(),
|
||||
tabs,
|
||||
"the tabs stayed where they were"
|
||||
);
|
||||
// An order that is not a permutation of the list changes nothing.
|
||||
app.apply_pinned_order(&[0, 1], cx);
|
||||
assert_eq!(app.sidebar_groups.pinned.len(), 3);
|
||||
});
|
||||
}
|
||||
|
||||
/// "New Tab" on a pinned group opens a tab in it — joining outright,
|
||||
/// without waiting for a cwd to walk in, since a label group has nothing
|
||||
/// to walk into.
|
||||
#[gpui::test]
|
||||
fn a_label_group_can_be_filled_from_its_header(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||||
|
||||
let before = app.update(&mut vcx, |app, _| app.tabs.len());
|
||||
let work = app.update_in(&mut vcx, |app, window, cx| {
|
||||
let work = label(app, "work", cx);
|
||||
app.new_tab_in_group(GroupKey::Pinned(work), window, cx);
|
||||
work
|
||||
});
|
||||
vcx.run_until_parked();
|
||||
|
||||
app.update(&mut vcx, |app, _| {
|
||||
// A harness without a daemon may refuse to spawn; when it does
|
||||
// open one, it has to be in the group.
|
||||
if app.tabs.len() > before {
|
||||
assert_eq!(app.tabs[app.active].group.get(), Some(work));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clearing a folder keeps the name the header was showing: a folder
|
||||
/// group nobody renamed reads its folder's name, and would read nothing
|
||||
/// once the folder is gone.
|
||||
#[gpui::test]
|
||||
fn clearing_a_folder_keeps_the_name_it_was_showing(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _streams) = harness_with_tabs(cx, 1);
|
||||
|
||||
app.update(&mut vcx, |app, cx| {
|
||||
let tty7 = folder(app, "/w/tty7", cx);
|
||||
app.set_group_folder(tty7, None, cx);
|
||||
let group = app.sidebar_groups.get(tty7).expect("still pinned");
|
||||
assert_eq!(group.folder, None);
|
||||
assert_eq!(group.given_name(), Some("tty7"));
|
||||
app.set_group_folder(tty7, Some(PathBuf::from("/w/else")), cx);
|
||||
let group = app.sidebar_groups.get(tty7).expect("still pinned");
|
||||
assert_eq!(group.folder.as_deref(), Some("/w/else"));
|
||||
assert_eq!(group.given_name(), Some("tty7"), "a set name stays");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user