diff --git a/Cargo.lock b/Cargo.lock
index 40cc8052..c7efd7a7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1880,6 +1880,7 @@ dependencies = [
"gpui-pre",
"log",
"settings",
+ "window-wrapper",
]
[[package]]
@@ -10835,6 +10836,7 @@ dependencies = [
"serde_json",
"settings",
"table",
+ "window-wrapper",
]
[[package]]
diff --git a/assets/icons/map-pin.svg b/assets/icons/map-pin.svg
new file mode 100644
index 00000000..9029b306
--- /dev/null
+++ b/assets/icons/map-pin.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/message-square.svg b/assets/icons/message-square.svg
new file mode 100644
index 00000000..89008980
--- /dev/null
+++ b/assets/icons/message-square.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/mouse-pointer-2.svg b/assets/icons/mouse-pointer-2.svg
new file mode 100644
index 00000000..21a7c20a
--- /dev/null
+++ b/assets/icons/mouse-pointer-2.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/pencil.svg b/assets/icons/pencil.svg
new file mode 100644
index 00000000..f458a3f5
--- /dev/null
+++ b/assets/icons/pencil.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/rectangle.svg b/assets/icons/rectangle.svg
new file mode 100644
index 00000000..1df393fd
--- /dev/null
+++ b/assets/icons/rectangle.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/square-pen.svg b/assets/icons/square-pen.svg
new file mode 100644
index 00000000..56ca1e39
--- /dev/null
+++ b/assets/icons/square-pen.svg
@@ -0,0 +1 @@
+
diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index ef91e7b6..513d128c 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -1,7 +1,8 @@
//! Our own icons in front of `gpui_component_assets`, which is otherwise the only asset source.
//! The bundled icon set has no filled panel glyphs (the title bar's "this dock is open" state) and
//! no picture glyph (visual search), and none of the pop-out viewer's window, pin or missing-file
-//! glyphs, nor the viewer's fit-to-width glyph, so those SVGs are ours, copied from Lucide.
+//! glyphs, nor the viewer's fit-to-width or annotation glyphs, so those SVGs are ours, copied from
+//! Lucide.
use std::borrow::Cow;
@@ -9,7 +10,7 @@ use gpui::{AssetSource, Result, SharedString};
pub struct Assets;
-const OWN: [(&str, &str); 10] = [
+const OWN: [(&str, &str); 16] = [
(
"icons/history.svg",
include_str!("../../../assets/icons/history.svg"),
@@ -50,6 +51,30 @@ const OWN: [(&str, &str); 10] = [
"icons/move-horizontal.svg",
include_str!("../../../assets/icons/move-horizontal.svg"),
),
+ (
+ "icons/square-pen.svg",
+ include_str!("../../../assets/icons/square-pen.svg"),
+ ),
+ (
+ "icons/mouse-pointer-2.svg",
+ include_str!("../../../assets/icons/mouse-pointer-2.svg"),
+ ),
+ (
+ "icons/rectangle.svg",
+ include_str!("../../../assets/icons/rectangle.svg"),
+ ),
+ (
+ "icons/map-pin.svg",
+ include_str!("../../../assets/icons/map-pin.svg"),
+ ),
+ (
+ "icons/message-square.svg",
+ include_str!("../../../assets/icons/message-square.svg"),
+ ),
+ (
+ "icons/pencil.svg",
+ include_str!("../../../assets/icons/pencil.svg"),
+ ),
];
impl AssetSource for Assets {
diff --git a/crates/app/src/export.rs b/crates/app/src/export.rs
index b28ce414..60762ac2 100644
--- a/crates/app/src/export.rs
+++ b/crates/app/src/export.rs
@@ -398,6 +398,9 @@ fn sheet_notes(file: &Path, grid: &ExportGrid) -> anyhow::Result>
message: note.message,
created_at: note.created_at,
author: note.author,
+ id: Some(note.id),
+ region: note.region.and_then(|r| serde_json::to_string(&r).ok()),
+ kind: note.kind.map(|kind| kind.key().to_string()),
})
.collect::>();
Ok(qrate_export::sheet_notes(
diff --git a/crates/app/src/macos_services.rs b/crates/app/src/macos_services.rs
index 42047e4b..3d0c6c07 100644
--- a/crates/app/src/macos_services.rs
+++ b/crates/app/src/macos_services.rs
@@ -1,4 +1,8 @@
-use std::{cell::RefCell, ffi::CStr, sync::OnceLock};
+use std::{
+ cell::RefCell,
+ ffi::{CStr, c_void},
+ sync::OnceLock,
+};
use cocoa::{
base::{id, nil},
@@ -28,7 +32,7 @@ pub(crate) fn register(sender: async_channel::Sender) {
.expect("qrate's folder service class is registered once");
provider.add_method(
sel!(openWithQrate:userData:error:),
- open_folder as extern "C" fn(&Object, Sel, id, id, *mut id),
+ open_folder as extern "C" fn(&Object, Sel, id, id, *mut c_void),
);
let object: id = msg_send![provider.register(), new];
let app: id = msg_send![class!(NSApplication), sharedApplication];
@@ -37,7 +41,7 @@ pub(crate) fn register(sender: async_channel::Sender) {
}
}
-extern "C" fn open_folder(_: &Object, _: Sel, pasteboard: id, _: id, error: *mut id) {
+extern "C" fn open_folder(_: &Object, _: Sel, pasteboard: id, _: id, error: *mut c_void) {
unsafe {
let classes: id = msg_send![class!(NSArray), arrayWithObject: class!(NSURL)];
let urls: id = msg_send![pasteboard, readObjectsForClasses: classes options: nil];
@@ -64,7 +68,7 @@ extern "C" fn open_folder(_: &Object, _: Sel, pasteboard: id, _: id, error: *mut
if !error.is_null() {
let message =
NSString::alloc(nil).init_str("Choose a folder to start a qrate project.");
- *error = msg_send![message, autorelease];
+ *error.cast::() = msg_send![message, autorelease];
}
}
}
diff --git a/crates/app/src/status_items/plugin_bar.rs b/crates/app/src/status_items/plugin_bar.rs
index 10644e59..09c16c71 100644
--- a/crates/app/src/status_items/plugin_bar.rs
+++ b/crates/app/src/status_items/plugin_bar.rs
@@ -33,6 +33,43 @@ impl PluginBar {
_sub: cx.observe_global::(|_, cx| cx.notify()),
}
}
+
+ /// Menu equivalents of the bar contributions, using the same command dispatcher.
+ pub fn overflow_menu(
+ bar: Bar,
+ menu: PopupMenu,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> PopupMenu {
+ let items = [Side::Left, Side::Right]
+ .into_iter()
+ .flat_map(|side| BarContributions::at(bar, side, cx))
+ .collect::>();
+ if items.is_empty() {
+ return menu;
+ }
+ menu.submenu("Plugin controls", window, cx, move |menu, window, cx| {
+ items.iter().cloned().fold(menu, |menu, (plugin, item)| {
+ let mut actions = Vec::new();
+ for action in [item.left, item.right].into_iter().flatten() {
+ match action {
+ BarAction::Command(command) => actions.push((item.text.clone(), command)),
+ BarAction::Menu(commands) => actions.extend(commands),
+ }
+ }
+ // One command may be exposed by both mouse buttons.
+ let mut seen = std::collections::HashSet::new();
+ actions.retain(|(_, command)| seen.insert(command.clone()));
+ if actions.is_empty() {
+ menu.label(item.text)
+ } else {
+ menu.submenu(item.text, window, cx, move |menu, _, _| {
+ entries(plugin.clone(), actions.clone(), menu)
+ })
+ }
+ })
+ })
+ }
}
/// Run one of a plugin's commands against whatever the table has selected. The command answers on
diff --git a/crates/app/src/title_items/mod.rs b/crates/app/src/title_items/mod.rs
index 8625b738..37527848 100644
--- a/crates/app/src/title_items/mod.rs
+++ b/crates/app/src/title_items/mod.rs
@@ -1,12 +1,14 @@
pub mod launcher_bar;
mod update_notice;
+use std::rc::Rc;
+
use gpui::*;
use gpui_component::{
ActiveTheme,
dock::{DockArea, DockPlacement},
h_flex,
- menu::AppMenuBar,
+ menu::{AppMenuBar, PopupMenu, PopupMenuItem},
};
use plugin_api::{Bar, Side};
@@ -39,6 +41,56 @@ impl Render for FeedbackButton {
/// are registered on the left by default. Right: generic open/close buttons for each dock.
pub fn build_title_bar_registry(cx: &mut App, dock: WeakEntity) -> TitleBarRegistry {
let mut registry = TitleBarRegistry::default();
+ let menu_dock = dock.clone();
+ registry.overflow_menu = Some(Rc::new(move |menu, window, cx, focus| {
+ let menus = gpui_component::GlobalState::global(cx).app_menus().to_vec();
+ let menu = menus.into_iter().fold(menu, |menu, app_menu| {
+ let focus = focus.clone();
+ let submenu = PopupMenu::build(window, cx, move |menu, window, cx| {
+ append_menu_items(menu, app_menu.items, window, cx, focus)
+ });
+ menu.item(PopupMenuItem::submenu(app_menu.name, submenu).disabled(app_menu.disabled))
+ });
+ let menu = PluginBar::overflow_menu(Bar::Title, menu, window, cx);
+ let dock = menu_dock.clone();
+ let dock_focus = focus.clone();
+ let menu = menu
+ .separator()
+ .item(PopupMenuItem::new("Feedback").on_click(|_, _, cx| {
+ cx.open_url(&crate::logging::feedback_url(cx, None));
+ }))
+ .submenu("Panels", window, cx, move |menu, _, cx| {
+ let menu = match &dock_focus {
+ Some(focus) => menu.action_context(focus.clone()),
+ None => menu,
+ };
+ [
+ (
+ "Left dock",
+ DockPlacement::Left,
+ Box::new(ToggleLeftDock) as Box,
+ ),
+ (
+ "Bottom dock",
+ DockPlacement::Bottom,
+ Box::new(ToggleBottomDock),
+ ),
+ (
+ "Right dock",
+ DockPlacement::Right,
+ Box::new(ToggleRightDock),
+ ),
+ ]
+ .into_iter()
+ .fold(menu, |menu, (label, placement, action)| {
+ let open = dock
+ .upgrade()
+ .is_some_and(|dock| dock.read(cx).is_dock_open(placement));
+ menu.menu_with_check(label, open, action)
+ })
+ });
+ update_notice::overflow_menu(menu, cx)
+ }));
// The library's menu bar, not a row of independent dropdowns: it holds the "a menu is open"
// state that makes hovering a sibling switch to it, and arrow keys walk between them.
@@ -89,3 +141,37 @@ pub fn build_title_bar_registry(cx: &mut App, dock: WeakEntity) -> Tit
registry
}
+
+/// Convert the same owned menus AppMenuBar reads, preserving action, check, and disabled state.
+fn append_menu_items(
+ mut menu: PopupMenu,
+ items: Vec,
+ window: &mut Window,
+ cx: &mut Context,
+ focus: Option,
+) -> PopupMenu {
+ if let Some(focus) = &focus {
+ menu = menu.action_context(focus.clone());
+ }
+ for item in items {
+ menu = match item {
+ OwnedMenuItem::Action {
+ name,
+ action,
+ checked,
+ disabled,
+ ..
+ } => menu.menu_with_check_and_disabled(name, checked, action.boxed_clone(), disabled),
+ OwnedMenuItem::Separator => menu.separator(),
+ OwnedMenuItem::Submenu(submenu) => {
+ let focus = focus.clone();
+ let child = PopupMenu::build(window, cx, move |menu, window, cx| {
+ append_menu_items(menu, submenu.items, window, cx, focus)
+ });
+ menu.item(PopupMenuItem::submenu(submenu.name, child).disabled(submenu.disabled))
+ }
+ OwnedMenuItem::SystemMenu(_) => menu,
+ };
+ }
+ menu.scrollable(true)
+}
diff --git a/crates/app/src/title_items/update_notice.rs b/crates/app/src/title_items/update_notice.rs
index ce662f55..64d67ce5 100644
--- a/crates/app/src/title_items/update_notice.rs
+++ b/crates/app/src/title_items/update_notice.rs
@@ -8,6 +8,48 @@ use gpui_component::{
};
use crate::update_check::{AutoUpdater, UpdateStatus};
+use gpui_component::menu::{PopupMenu, PopupMenuItem};
+
+pub(super) fn overflow_menu(menu: PopupMenu, cx: &mut Context) -> PopupMenu {
+ let Some(updater) = AutoUpdater::get(cx) else {
+ return menu;
+ };
+ if !updater.read(cx).visible() {
+ return menu;
+ }
+ let status = updater.read(cx).status().clone();
+ let menu = menu.separator();
+ let menu = match &status {
+ UpdateStatus::Downloading { version, .. } => menu.item(
+ PopupMenuItem::new(format!(
+ "Downloading qrate v{version}… {}%",
+ status.progress_percent().unwrap_or_default()
+ ))
+ .disabled(true),
+ ),
+ UpdateStatus::Ready { .. } => {
+ menu.item(PopupMenuItem::new("Restart to update").on_click(crate::restart_for_update))
+ }
+ UpdateStatus::Restarting => menu.item(PopupMenuItem::new("Restarting…").disabled(true)),
+ UpdateStatus::Error { .. } => menu.item(
+ PopupMenuItem::new("Update failed — open About")
+ .on_click(|_, _, cx| crate::about::open_about_window(cx)),
+ ),
+ _ => return menu,
+ };
+ if matches!(
+ status,
+ UpdateStatus::Ready { .. } | UpdateStatus::Error { .. }
+ ) {
+ menu.item(
+ PopupMenuItem::new("Dismiss update notice").on_click(move |_, _, cx| {
+ updater.update(cx, |updater, cx| updater.dismiss(cx));
+ }),
+ )
+ } else {
+ menu
+ }
+}
pub struct UpdateNotice {
updater: Option>,
diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml
index fc86c49e..30880bd0 100644
--- a/crates/diagnostics/Cargo.toml
+++ b/crates/diagnostics/Cargo.toml
@@ -8,6 +8,7 @@ log.workspace = true
gpui.workspace = true
gpui-component.workspace = true
settings = { path = "../settings" }
+window-wrapper = { path = "../window-wrapper" }
[dev-dependencies]
# `#[gpui::test]`, TestAppContext, and run_test are all behind this feature.
diff --git a/crates/diagnostics/src/fixes.rs b/crates/diagnostics/src/fixes.rs
index e9411672..66f723a2 100644
--- a/crates/diagnostics/src/fixes.rs
+++ b/crates/diagnostics/src/fixes.rs
@@ -404,7 +404,7 @@ mod tests {
source: Source::Validator(source.into()),
message: "not a known term".into(),
group: None,
- filed: None,
+ note: None,
}],
cx,
);
@@ -516,7 +516,7 @@ mod tests {
source: Source::Validator("LCSH".into()),
message: "not a known term".into(),
group: None,
- filed: None,
+ note: None,
},
Diagnostic {
location: location("Subject"),
@@ -524,7 +524,7 @@ mod tests {
source: Source::Validator("LCSH".into()),
message: "also deprecated".into(),
group: None,
- filed: None,
+ note: None,
},
];
Diagnostics::set(
diff --git a/crates/diagnostics/src/lib.rs b/crates/diagnostics/src/lib.rs
index 7a609164..a005a4a8 100644
--- a/crates/diagnostics/src/lib.rs
+++ b/crates/diagnostics/src/lib.rs
@@ -6,6 +6,7 @@
//! panel asks the app to reveal a cell.
pub mod fixes;
+pub mod note_card;
mod panel;
mod validator;
pub use fixes::{Fix, FixProviders, GroupFix, GroupFixProviders, GroupMember, SourceActions};
@@ -20,7 +21,8 @@ use std::path::PathBuf;
use gpui::{App, Global, Hsla, SharedString};
use gpui_component::ActiveTheme as _;
-use settings::history::{Change, Entry, Origin};
+use settings::history::{Change, Entry, EntryId, Origin};
+pub use settings::project::{NoteId, NoteKind, Region};
/// The one dataset a project can hold today. `__notes` keys by name so a second sheet is new
/// rows rather than a new table.
@@ -176,9 +178,29 @@ pub struct Diagnostic {
pub source: Source,
pub message: SharedString,
pub group: Option,
- /// Who filed this and when, for authored notes. Always `None` on a computed finding — a
- /// validator's output is recomputed on open, so it has no history to carry.
+ /// Set on every authored note, never on a computed finding — a validator's output is
+ /// recomputed on open, so it has no identity or history to carry.
+ pub note: Option,
+}
+
+/// What an authored note carries beyond its text.
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub struct NoteMeta {
+ pub id: NoteId,
pub filed: Option,
+ /// Where on the row's file it points; `None` for a note on the data itself.
+ pub region: Option,
+ pub kind: Option,
+ /// When its words or kind last changed, and by whom.
+ pub edited: Option,
+}
+
+/// Everything a note write can change.
+#[derive(Clone, PartialEq, Eq, Debug)]
+struct Body {
+ message: SharedString,
+ region: Option,
+ kind: Option,
}
impl Diagnostic {
@@ -279,6 +301,11 @@ pub struct Diagnostics {
/// a key carrying the column name would allocate a `SharedString` on every lookup and give
/// the cost straight back.
by_row: HashMap