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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions crates/dashboard-app/src/captures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1482,3 +1482,194 @@ fn captures_fleet_runtime_ages() {
}
}
}

fn has_focus(cx: &mut HeadlessAppContext, handle: AnyWindowHandle, id: &'static str) -> bool {
cx.update_window(handle, |_, window, _| window.find(id).focused() == Some(true))
.unwrap()
}

/// Presses `step` (Tab or Shift+Tab) until `id` has focus.
fn tab_to(cx: &mut HeadlessAppContext, handle: AnyWindowHandle, step: &str, id: &'static str) {
for _ in 0..40 {
if has_focus(cx, handle, id) {
return;
}
key(cx, handle, &[step]);
}
panic!("{id} never took focus");
}

/// Opens a Manage dialog on `write` at the default window size and theme.
fn open_manage_dialog(
cx: &mut HeadlessAppContext,
write: Arc<dyn dashboard_core::write::WriteBackend>,
config_path: Option<PathBuf>,
feature: dashboard_core::write::Feature,
) -> (AnyWindowHandle, Entity<crate::manage::Manage>) {
let (handle, view) = open_with(
cx,
demo::checkouts(),
1100.,
760.,
Options {
write,
config_path: config_path.or(options().config_path),
..options()
},
);
fill(cx, &view, &demo::DemoSource);
let manage = cx.update(|cx| view.read(cx).manage().clone());
settle(cx);
cx.update_window(handle, |_, window, cx| {
manage.update(cx, |m, cx| {
m.open_menu(window, cx);
m.activate(crate::manage::Entry::Feature(feature), window, cx);
})
})
.unwrap();
settle(cx);
(handle, manage)
}

/// Issue 91, Work items: a priority save that moves the item keeps it selected. Keyboard only, from a pmd that lists in
/// its own order. Each state is saved, then checked, so the old code still writes the state it gets wrong.
#[test]
#[ignore = "writes issue 91 Work items selection states offscreen"]
fn captures_work_selection_after_reorder() {
use crate::work_items_tests::{FakeWork, sample_items};
use std::sync::atomic::Ordering;
let out = PathBuf::from(std::env::var_os("CAPTURE_DIR").expect("CAPTURE_DIR"));
std::fs::create_dir_all(&out).unwrap();
let mut cx = headless();
let backend = FakeWork::new(&["read", "write:work"], sample_items());
backend.in_pmd_order.store(true, Ordering::SeqCst);
let (handle, manage) = open_manage_dialog(
&mut cx,
backend.clone(),
None,
dashboard_core::write::Feature::WorkItems,
);
let numbers = |cx: &mut HeadlessAppContext| {
cx.update(|cx| {
let work = manage.read(cx).work_items().unwrap();
let numbers: Vec<u64> = work.items().iter().map(|item| item.number).collect();
(numbers, work.selected())
})
};
let editing = |cx: &mut HeadlessAppContext| {
cx.update(|cx| {
manage
.read(cx)
.work_items()
.unwrap()
.editor_item()
.map(|item| item.number)
})
};

// Down to the codexbar PR, #202, fifth in pmd's order.
key(&mut cx, handle, &["down", "down", "down", "down"]);
save(&mut cx, handle, &out, "selection-work-1-before");
assert_eq!(numbers(&mut cx), (vec![203, 205, 200, 201, 202, 204], 4));

// Enter opens it; Tab to P0, Enter picks it, Ctrl+Enter saves it.
key(&mut cx, handle, &["enter"]);
assert_eq!(editing(&mut cx), Some(202));
tab_to(&mut cx, handle, "tab", "work-priority-0");
key(&mut cx, handle, &["enter", "ctrl-enter"]);
settle(&mut cx);
save(&mut cx, handle, &out, "selection-work-2-saved");
let writes = backend.writes.lock().unwrap().clone();
assert_eq!(writes.len(), 1);
assert_eq!(writes[0].path, "v1/work/01JBXWORK0002");
assert_eq!(writes[0].body, serde_json::json!({"priority": 0}));

// Tab to Back to list, Enter: the highlight is on #202, now first.
tab_to(&mut cx, handle, "tab", "work-back");
key(&mut cx, handle, &["enter"]);
save(&mut cx, handle, &out, "selection-work-3-after-back");
assert!(has_focus(&mut cx, handle, "work-list"));
assert_eq!(numbers(&mut cx), (vec![202, 203, 205, 200, 201, 204], 0));

// Enter reopens #202.
key(&mut cx, handle, &["enter"]);
save(&mut cx, handle, &out, "selection-work-4-enter");
assert_eq!(editing(&mut cx), Some(202));
}

/// Issue 91, Projects: a priority save that re-sorts the project keeps it selected. Keyboard only; each state is saved,
/// then checked.
#[test]
#[ignore = "writes issue 91 Projects selection states offscreen"]
fn captures_projects_selection_after_reorder() {
use crate::projects_tests::{FakeProjects, Folder};
let out = PathBuf::from(std::env::var_os("CAPTURE_DIR").expect("CAPTURE_DIR"));
std::fs::create_dir_all(&out).unwrap();
let mut cx = headless();
let folder = Folder::new();
let backend = FakeProjects::new(&["read", "write:projects"]);
let (handle, manage) = open_manage_dialog(
&mut cx,
backend.clone(),
Some(folder.config_dir().join("config.toml")),
dashboard_core::write::Feature::Projects,
);
let listed = |cx: &mut HeadlessAppContext| {
cx.update(|cx| {
let projects = manage.read(cx).projects().unwrap();
(projects.visible_slugs(cx), projects.selected())
})
};
let editing = |cx: &mut HeadlessAppContext| {
cx.update(|cx| {
manage
.read(cx)
.projects()
.unwrap()
.editor_form()
.map(|form| form.value("slug").to_owned())
})
};
let slugs = |slugs: &[&str]| slugs.iter().map(|slug| (*slug).to_owned()).collect::<Vec<_>>();

// Down to gh-x, P3.
key(&mut cx, handle, &["down", "down", "down"]);
save(&mut cx, handle, &out, "selection-projects-1-before");
assert_eq!(
listed(&mut cx),
(
slugs(&["project-dashboard", "set-it-free-loop", "hs-buddy", "gh-x", "chief"]),
3
)
);

// Enter edits it with focus on P3; Shift+Tab to P0, Enter picks it, Ctrl+Enter saves it.
key(&mut cx, handle, &["enter"]);
assert_eq!(editing(&mut cx).as_deref(), Some("gh-x"));
tab_to(&mut cx, handle, "shift-tab", "project-priority-0");
key(&mut cx, handle, &["enter", "ctrl-enter"]);
settle(&mut cx);
save(&mut cx, handle, &out, "selection-projects-2-saved");
let writes = backend.writes.lock().unwrap().clone();
assert_eq!(writes.len(), 1);
assert_eq!(writes[0].path, "v1/projects/gh-x");
assert_eq!(writes[0].body["priority"], serde_json::json!(0));

// Tab to Back to list, Enter: the highlight is on gh-x, now first.
tab_to(&mut cx, handle, "tab", "projects-back");
key(&mut cx, handle, &["enter"]);
save(&mut cx, handle, &out, "selection-projects-3-after-back");
assert!(has_focus(&mut cx, handle, "projects-list"));
assert_eq!(
listed(&mut cx),
(
slugs(&["gh-x", "project-dashboard", "set-it-free-loop", "hs-buddy", "chief"]),
0
)
);

// Enter reopens gh-x.
key(&mut cx, handle, &["enter"]);
save(&mut cx, handle, &out, "selection-projects-4-enter");
assert_eq!(editing(&mut cx).as_deref(), Some("gh-x"));
}
48 changes: 37 additions & 11 deletions crates/dashboard-app/src/manage/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::sync::Arc;
use dashboard_core::config::Config;
use dashboard_core::edit::{Form, Next};
use dashboard_core::projects::{self, FIELDS, Project};
use dashboard_core::selection;
use dashboard_core::write::{CreateCheck, Feature, Method, WriteError, WriteRequest, write_state};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::{ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, h_flex, v_flex};
Expand Down Expand Up @@ -41,14 +42,16 @@ enum After {
}

/// Puts a project pmd returned in the list, in order. A project the list holds at a newer version stays: a late
/// answer never undoes a later write.
fn upsert(projects: &mut ProjectsDialog, project: Project) {
match projects.rows.iter_mut().find(|row| row.slug == project.slug) {
Some(row) if row.version > project.version => {}
Some(row) => *row = project,
None => projects.rows.push(project),
}
projects::sort(&mut projects.rows);
/// answer never undoes a later write. The selection stays on its project.
fn upsert(projects: &mut ProjectsDialog, project: Project, cx: &gpui_kit::App) {
projects.keep_selection(cx, |projects| {
match projects.rows.iter_mut().find(|row| row.slug == project.slug) {
Some(row) if row.version > project.version => {}
Some(row) => *row = project,
None => projects.rows.push(project),
}
projects::sort(&mut projects.rows);
});
}

/// The priority choices: P0 to P5, then no tier (empty).
Expand Down Expand Up @@ -120,6 +123,22 @@ impl ProjectsDialog {
.collect()
}

/// Changes the rows and keeps the selection on the same project (#91): where a re-sort moved it or, once it is no
/// longer listed (untracked while untracked rows are hidden), on the row at its old position.
fn keep_selection(&mut self, cx: &gpui_kit::App, change: impl FnOnce(&mut Self)) {
let previous = self
.visible(cx)
.get(self.selected)
.map(|&row| self.rows[row].slug.clone());
change(self);
let visible = self.visible(cx);
self.selected = selection::reselect(
previous.as_deref(),
self.selected,
visible.iter().map(|&row| self.rows[row].slug.as_str()),
);
}

fn is_linked(&self, project: &Project) -> Option<bool> {
self.linked
.as_ref()
Expand All @@ -134,6 +153,12 @@ impl ProjectsDialog {
.collect()
}

/// The selected row's position among the visible rows.
#[cfg(test)]
pub fn selected(&self) -> usize {
self.selected
}

#[cfg(test)]
pub fn editor_form(&self) -> Option<&Form> {
self.editor.as_ref().map(|editor| &editor.form)
Expand Down Expand Up @@ -291,7 +316,7 @@ impl Manage {
}
}
projects::sort(&mut rows);
projects.rows = rows;
projects.keep_selection(cx, |projects| projects.rows = rows);
projects.load_error = None;
}
// The last good list stays beside the error.
Expand Down Expand Up @@ -442,6 +467,7 @@ impl Manage {
if let Some((_, projects)) = self.open_projects() {
projects.editor = None;
projects.list.focus(window, cx);
projects.scroll.scroll_to_item(projects.selected);
}
cx.notify();
}
Expand Down Expand Up @@ -557,7 +583,7 @@ impl Manage {
// the dialog closed while it was on its way.
cx.emit(ManageEvent::Wrote);
if let Some(projects) = manage.projects_of(id) {
upsert(projects, saved.clone());
upsert(projects, saved.clone(), cx);
}
}
let projects = manage.projects_of(id)?;
Expand Down Expand Up @@ -619,7 +645,7 @@ impl Manage {
};
// pmd's current project goes in the list, in order; a created one also reloads the table.
if let Ok(fresh) = &fresh {
upsert(projects, fresh.clone());
upsert(projects, fresh.clone(), cx);
if after == After::Found {
cx.emit(ManageEvent::Wrote);
}
Expand Down
13 changes: 12 additions & 1 deletion crates/dashboard-app/src/manage/work_items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use std::sync::Arc;

use dashboard_core::edit::{Form, Next};
use dashboard_core::selection;
use dashboard_core::work::{
self, AUTO, FIELDS, Kind, MAX_REASON, STATUSES, WorkFilter, WorkItem, WorkPage, status_label,
};
Expand Down Expand Up @@ -279,9 +280,19 @@ impl Manage {
match result {
Ok(page) => {
if cursor.is_none() {
// Under the same filters the selected item stays selected wherever the new page puts
// it (#91), after a save reorders it or a change elsewhere moves it. Under new filters
// the position stays, as before; a filter chip has already reset it to the first row.
let previous = (work.rows_filter == filter)
.then(|| work.items.get(work.selected).map(|item| item.id.clone()))
.flatten();
work.rows_filter = filter;
work.items = page.items;
work.selected = work.selected.min(work.items.len().saturating_sub(1));
work.selected = selection::reselect(
previous.as_deref(),
work.selected,
work.items.iter().map(|item| item.id.as_str()),
);
} else {
work.items.extend(page.items);
}
Expand Down
Loading
Loading