дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(workbench): toggle the toolbar, status bar and tool window bars from View › Appearance (#2206)
The main toolbar and status bar could not be hidden, Tool Window Bars was a Settings row only, and widgets could be switched only from the status bar's context menu. View > Appearance now has Toolbar, Tool Window Bars, Status Bar and Status Bar Widgets, with check marks in the macOS and in-app menus, plus Settings toggles. Both new settings default to shown. With the toolbar hidden, the window keeps a compact header with the title, which Distraction Free now shares. Controls stay reachable with a bar hidden: the Git branches popup and background tasks have their own hosts, and background tasks has an action. Co-authored-by: lleviathe <[email protected]>
Цей коміт міститься в:
1 джерело
75565927d8
коміт
8dd29bad41
42 files changed
+1001
-144
No files matched your search
@@ -395,6 +395,9 @@ pub struct AppearanceSettings {
|
||||
pub show_tool_window_names: bool,
|
||||
pub show_tool_window_badges: bool,
|
||||
pub widescreen_tool_windows: bool,
|
||||
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
|
||||
pub show_main_toolbar: bool,
|
||||
pub show_status_bar: bool,
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
@@ -465,6 +468,8 @@ impl Default for AppearanceSettings {
|
||||
show_tool_window_names: false,
|
||||
show_tool_window_badges: true,
|
||||
widescreen_tool_windows: false,
|
||||
show_main_toolbar: true,
|
||||
show_status_bar: true,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
|
||||
@@ -643,6 +643,22 @@ mod tests {
|
||||
let s = store.set(SettingsScope::Global, "appearance.mainToolbar", Json::Null).unwrap();
|
||||
assert_eq!(s.appearance.main_toolbar, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_and_status_bar_are_shown_until_hidden() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("settings.toml"), "[appearance]\ncompactMode = true\n").unwrap();
|
||||
let store = SettingsStore::load(tmp.path(), None);
|
||||
let s = store.effective();
|
||||
assert!(s.appearance.show_main_toolbar);
|
||||
assert!(s.appearance.show_status_bar);
|
||||
let s = store.set(SettingsScope::Global, "appearance.showMainToolbar", json!(false)).unwrap();
|
||||
assert!(!s.appearance.show_main_toolbar);
|
||||
let s = store.set(SettingsScope::Global, "appearance.showStatusBar", json!(false)).unwrap();
|
||||
assert!(!s.appearance.show_status_bar);
|
||||
assert!(!store.reload().appearance.show_main_toolbar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_is_default_off_and_cannot_be_enabled_by_a_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -252,16 +252,30 @@ pub fn app_cancel_quit(app: AppHandle, windows: State<'_, WindowsState>) {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn menu_configure(
|
||||
window: WebviewWindow,
|
||||
app: AppHandle,
|
||||
menu_state: State<'_, MenuState>,
|
||||
state: State<'_, AppState>,
|
||||
config: MenuConfig,
|
||||
) {
|
||||
if menu::native_menu_bar()
|
||||
&& windows::target_window(&app).is_some_and(|target| !owns_menu_config(window.label(), target.label()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if menu::store_config(&menu_state, config) {
|
||||
menu::install(&app, &menu_state, &state.recent.list());
|
||||
}
|
||||
}
|
||||
|
||||
fn owns_menu_config(source: &str, target: &str) -> bool {
|
||||
let owner = match windows::auxiliary(target) {
|
||||
Some((windows::AuxKind::Settings, parent)) => parent,
|
||||
_ => target,
|
||||
};
|
||||
source == owner
|
||||
}
|
||||
|
||||
/// Updates the menu after the recent-projects list changed. Only the Open Recent
|
||||
/// submenu is rewritten when the rest of the menu is unchanged.
|
||||
#[tauri::command]
|
||||
@@ -588,6 +602,21 @@ pub async fn app_install_update(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn menu_configuration_follows_the_active_window_or_its_settings_parent() {
|
||||
for (source, target, allowed) in [
|
||||
("main", "main", true),
|
||||
("project-1", "main", false),
|
||||
("main", "settings-main", true),
|
||||
("project-1", "settings-main", false),
|
||||
("project-1", "settings-project-1", true),
|
||||
("comparison-main-1", "comparison-main-1", true),
|
||||
("main", "comparison-main-1", false),
|
||||
] {
|
||||
assert_eq!(super::owns_menu_config(source, target), allowed, "{source} → {target}");
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn downloading(p: Option<UpdateProgress>) -> Option<(f64, Option<f64>)> {
|
||||
|
||||
@@ -306,6 +306,7 @@ fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
|
||||
/// Routes IDE menu items to the focused window's frontend (`executeAction`).
|
||||
fn on_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) {
|
||||
let id = event.id().as_ref();
|
||||
menu::restore_check(app, &app.state::<menu::MenuState>(), id);
|
||||
// A keymap accelerator only draws the shortcut: its key reached the menu because the keymap declined it.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !menu::fires_natively(&menu::spec(menu::Os::Mac), id)
|
||||
|
||||
+191
-12
@@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use tauri::menu::{IsMenuItem, Menu, MenuItem, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||
use tauri::menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
/// Event emitted to the focused window when an IDE menu item is clicked.
|
||||
@@ -143,6 +143,9 @@ pub enum Entry {
|
||||
accel: Option<&'static str>,
|
||||
/// Shortcut text after the label, for the Windows/Linux edit items without a keymap binding.
|
||||
hint: Option<&'static str>,
|
||||
/// A toggle (IDEA's ToggleAction) drawn with a check mark; on when its id is in
|
||||
/// [`MenuConfig::checked`].
|
||||
check: bool,
|
||||
},
|
||||
Native(Native),
|
||||
Separator,
|
||||
@@ -157,19 +160,24 @@ fn sub(id: &'static str, label: &'static str, children: Vec<Entry>) -> Entry {
|
||||
Entry::Submenu { id, label, children }
|
||||
}
|
||||
fn act(id: &'static str, label: &'static str) -> Entry {
|
||||
Entry::Action { id, label, accel: None, hint: None }
|
||||
Entry::Action { id, label, accel: None, hint: None, check: false }
|
||||
}
|
||||
fn accel(id: &'static str, label: &'static str, accel: &'static str) -> Entry {
|
||||
Entry::Action { id, label, accel: Some(accel), hint: None }
|
||||
Entry::Action { id, label, accel: Some(accel), hint: None, check: false }
|
||||
}
|
||||
fn hinted(id: &'static str, label: &'static str, hint: &'static str) -> Entry {
|
||||
Entry::Action { id, label, accel: None, hint: Some(hint) }
|
||||
Entry::Action { id, label, accel: None, hint: Some(hint), check: false }
|
||||
}
|
||||
fn toggle(id: &'static str, label: &'static str) -> Entry {
|
||||
Entry::Action { id, label, accel: None, hint: None, check: true }
|
||||
}
|
||||
const SEP: Entry = Entry::Separator;
|
||||
|
||||
/// [`Entry::Dynamic`] slots the frontend fills.
|
||||
pub const DYNAMIC_TOOL_WINDOWS: &str = "toolWindows";
|
||||
pub const DYNAMIC_LAYOUTS: &str = "toolWindowLayouts";
|
||||
/// View › Appearance › Status Bar Widgets: every titled widget with its check mark.
|
||||
pub const DYNAMIC_STATUS_BAR_WIDGETS: &str = "statusBarWidgets";
|
||||
/// Tools › entries of plugins (`[[contributes.menus]] menu = "tools"`).
|
||||
pub const DYNAMIC_PLUGIN_TOOLS: &str = "pluginTools";
|
||||
|
||||
@@ -372,7 +380,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
vec![
|
||||
act("editor.toggleZen", "Enter Distraction Free Mode"),
|
||||
act("window.toggleFullScreen", "Toggle Full Screen"),
|
||||
act("view.toggleCompactMode", "Compact Mode"),
|
||||
toggle("view.toggleCompactMode", "Compact Mode"),
|
||||
SEP,
|
||||
act("view.zoomIn", "Zoom In IDE"),
|
||||
act("view.zoomOut", "Zoom Out IDE"),
|
||||
@@ -380,6 +388,11 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
SEP,
|
||||
act("view.toggleTheme", "Toggle Light/Dark Theme"),
|
||||
act("toolbar.customize", "Customize Main Toolbar…"),
|
||||
SEP,
|
||||
toggle("view.toggleToolbar", "Toolbar"),
|
||||
toggle("view.toggleToolWindowBars", "Tool Window Bars"),
|
||||
toggle("view.toggleStatusBar", "Status Bar"),
|
||||
sub("statusBarWidgets", "Status Bar Widgets", vec![Entry::Dynamic(DYNAMIC_STATUS_BAR_WIDGETS)]),
|
||||
],
|
||||
),
|
||||
SEP,
|
||||
@@ -918,6 +931,9 @@ pub struct MenuConfig {
|
||||
/// [`Entry::Dynamic`] slot → its items, already localised.
|
||||
#[serde(default)]
|
||||
pub dynamic: HashMap<String, Vec<DynamicItem>>,
|
||||
/// Ids of the toggle actions currently on.
|
||||
#[serde(default)]
|
||||
pub checked: Vec<String>,
|
||||
}
|
||||
|
||||
/// An item of a frontend-listed menu slot.
|
||||
@@ -927,6 +943,9 @@ pub struct DynamicItem {
|
||||
pub action: String,
|
||||
pub arg: Option<String>,
|
||||
pub label: String,
|
||||
/// Drawn with a check mark in this state; `None` for a plain item.
|
||||
#[serde(default)]
|
||||
pub checked: Option<bool>,
|
||||
}
|
||||
|
||||
impl DynamicItem {
|
||||
@@ -1025,6 +1044,12 @@ pub fn apply_disabled<R: Runtime>(app: &AppHandle<R>, disabled: &HashSet<String>
|
||||
let _ = i.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
MenuItemKind::Check(i) => {
|
||||
let enabled = item_enabled(i.id().as_ref(), disabled);
|
||||
if i.is_enabled().is_ok_and(|e| e != enabled) {
|
||||
let _ = i.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
MenuItemKind::Submenu(s) => {
|
||||
if let Ok(children) = s.items() {
|
||||
walk(children, disabled)
|
||||
@@ -1077,10 +1102,12 @@ enum RenderedItem {
|
||||
items: Vec<RenderedItem>,
|
||||
},
|
||||
/// `accel` is the spec's accelerator, or else the keymap's from [`MenuConfig::accelerators`].
|
||||
/// `checked` is set for a toggle, drawn as a check item.
|
||||
Action {
|
||||
id: String,
|
||||
text: String,
|
||||
accel: Option<String>,
|
||||
checked: Option<bool>,
|
||||
},
|
||||
/// Always disabled (`No Recent Projects`).
|
||||
Placeholder {
|
||||
@@ -1124,10 +1151,11 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) -
|
||||
title: config.label(id, label),
|
||||
items: render_items(children, config, recent),
|
||||
}),
|
||||
Entry::Action { id, label, accel, hint } => out.push(RenderedItem::Action {
|
||||
Entry::Action { id, label, accel, hint, check } => out.push(RenderedItem::Action {
|
||||
id: (*id).into(),
|
||||
text: config.item_text(id, label, *hint),
|
||||
accel: accel.or_else(|| config.accelerator(id)).map(Into::into),
|
||||
checked: check.then(|| config.checked.iter().any(|c| c == id)),
|
||||
}),
|
||||
Entry::Dynamic(slot) => {
|
||||
for item in config.dynamic_items(slot) {
|
||||
@@ -1135,6 +1163,7 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) -
|
||||
id: item.item_id(),
|
||||
text: item.label.clone(),
|
||||
accel: config.dynamic_accelerator(item).map(Into::into),
|
||||
checked: item.checked,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1181,7 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) -
|
||||
id: format!("{RECENT_PREFIX}{}", item.path),
|
||||
text: item.label.clone(),
|
||||
accel: None,
|
||||
checked: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1163,7 +1193,8 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) -
|
||||
fn structure_key(item: &RenderedItem) -> (u8, &str) {
|
||||
match item {
|
||||
RenderedItem::Submenu { id, .. } => (0, *id),
|
||||
RenderedItem::Action { id, .. } => (1, id.as_str()),
|
||||
RenderedItem::Action { id, checked: None, .. } => (1, id.as_str()),
|
||||
RenderedItem::Action { id, checked: Some(_), .. } => (5, id.as_str()),
|
||||
RenderedItem::Placeholder { id, .. } => (2, id.as_str()),
|
||||
RenderedItem::Native { kind, .. } => (3, kind.key()),
|
||||
RenderedItem::Separator => (4, ""),
|
||||
@@ -1223,10 +1254,14 @@ fn append_rendered<R: Runtime>(
|
||||
append_rendered(app, &child, items, disabled)?;
|
||||
parent.append(&child)?;
|
||||
}
|
||||
RenderedItem::Action { id, text, accel } => {
|
||||
RenderedItem::Action { id, text, accel, checked: None } => {
|
||||
let enabled = item_enabled(id, disabled);
|
||||
parent.append(&MenuItem::with_id(app, id, text, enabled, accel.as_deref())?)?;
|
||||
}
|
||||
RenderedItem::Action { id, text, accel, checked: Some(checked) } => {
|
||||
let enabled = item_enabled(id, disabled);
|
||||
parent.append(&CheckMenuItem::with_id(app, id, text, enabled, *checked, accel.as_deref())?)?;
|
||||
}
|
||||
RenderedItem::Placeholder { id, text } => {
|
||||
parent.append(&MenuItem::with_id(app, id, text, false, None::<&str>)?)?;
|
||||
}
|
||||
@@ -1270,7 +1305,11 @@ fn existing_matches<R: Runtime>(existing: &MenuItemKind<R>, desired: &RenderedIt
|
||||
(MenuItemKind::Submenu(submenu), RenderedItem::Submenu { id, .. }) => {
|
||||
submenu.id().as_ref() == format!("submenu:{id}").as_str()
|
||||
}
|
||||
(MenuItemKind::MenuItem(item), RenderedItem::Action { id, .. } | RenderedItem::Placeholder { id, .. }) => {
|
||||
(
|
||||
MenuItemKind::MenuItem(item),
|
||||
RenderedItem::Action { id, checked: None, .. } | RenderedItem::Placeholder { id, .. },
|
||||
) => item.id().as_ref() == id.as_str(),
|
||||
(MenuItemKind::Check(item), RenderedItem::Action { id, checked: Some(_), .. }) => {
|
||||
item.id().as_ref() == id.as_str()
|
||||
}
|
||||
(MenuItemKind::Predefined(_), RenderedItem::Native { .. } | RenderedItem::Separator) => true,
|
||||
@@ -1346,6 +1385,16 @@ fn sync_item<R: Runtime>(
|
||||
&& (previous_text == text || item.set_text(text).is_ok())
|
||||
&& (previous_accel == accel || item.set_accelerator(accel.as_deref()).is_ok())
|
||||
}
|
||||
(
|
||||
MenuItemKind::Check(item),
|
||||
RenderedItem::Action { text: previous_text, accel: previous_accel, checked: previous_checked, .. },
|
||||
RenderedItem::Action { text, accel, checked: Some(checked), .. },
|
||||
) => {
|
||||
accelerator_patchable(previous_accel.as_deref(), accel.as_deref())
|
||||
&& (previous_text == text || item.set_text(text).is_ok())
|
||||
&& (previous_accel == accel || item.set_accelerator(accel.as_deref()).is_ok())
|
||||
&& (*previous_checked == Some(*checked) || item.set_checked(*checked).is_ok())
|
||||
}
|
||||
(MenuItemKind::MenuItem(item), _, RenderedItem::Placeholder { text, .. }) => item.set_text(text).is_ok(),
|
||||
(MenuItemKind::Predefined(item), _, RenderedItem::Native { text, .. }) => item.set_text(text).is_ok(),
|
||||
_ => false,
|
||||
@@ -1398,11 +1447,15 @@ pub enum MenuNode {
|
||||
children: Vec<MenuNode>,
|
||||
},
|
||||
/// Runs `id` with `arg`. Items with an `arg` (Open Recent) carry their final label.
|
||||
/// `checked` is the state of a frontend-listed toggle (Status Bar Widgets). Spec toggles
|
||||
/// leave it unset: the frontend menu reads their state from the action (`toggled`), the
|
||||
/// source [`MenuConfig::checked`] is built from for the native menu.
|
||||
Action {
|
||||
id: String,
|
||||
label: String,
|
||||
hint: Option<String>,
|
||||
arg: Option<String>,
|
||||
checked: Option<bool>,
|
||||
},
|
||||
/// A disabled placeholder such as "No Recent Projects".
|
||||
Placeholder {
|
||||
@@ -1427,12 +1480,14 @@ pub fn model(spec: &[Entry], recent: &[RecentItem], config: &MenuConfig) -> Vec<
|
||||
label: d.label.clone(),
|
||||
hint: None,
|
||||
arg: d.arg.clone(),
|
||||
checked: d.checked,
|
||||
})),
|
||||
Entry::Action { id, label, hint, .. } => out.push(MenuNode::Action {
|
||||
id: (*id).into(),
|
||||
label: (*label).into(),
|
||||
hint: hint.map(Into::into),
|
||||
arg: None,
|
||||
checked: None,
|
||||
}),
|
||||
Entry::Native(_) => {}
|
||||
Entry::Separator => out.push(MenuNode::Separator),
|
||||
@@ -1444,6 +1499,7 @@ pub fn model(spec: &[Entry], recent: &[RecentItem], config: &MenuConfig) -> Vec<
|
||||
label: r.label.clone(),
|
||||
hint: None,
|
||||
arg: Some(r.path.clone()),
|
||||
checked: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -1456,6 +1512,45 @@ pub fn native_menu_bar() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
}
|
||||
|
||||
/// The check state item `id` was last drawn with; `None` for a plain item.
|
||||
fn rendered_check(menus: &[RenderedMenu], id: &str) -> Option<bool> {
|
||||
fn find(items: &[RenderedItem], id: &str) -> Option<bool> {
|
||||
items.iter().find_map(|item| match item {
|
||||
RenderedItem::Submenu { items, .. } => find(items, id),
|
||||
RenderedItem::Action { id: item_id, checked, .. } if item_id == id => *checked,
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
menus.iter().find_map(|menu| find(&menu.items, id))
|
||||
}
|
||||
|
||||
/// Puts a clicked check item back to the state it was drawn with. muda flips it before the
|
||||
/// event arrives, but the frontend owns the state: a click it ignores (a modal Settings
|
||||
/// window, a failed save) must not leave a stale mark, and one it accepts comes back as a new
|
||||
/// config that sets the mark through [`sync_item`].
|
||||
pub fn restore_check<R: Runtime>(app: &AppHandle<R>, menu_state: &MenuState, id: &str) {
|
||||
fn walk<R: Runtime>(items: Vec<MenuItemKind<R>>, id: &str, checked: bool) -> bool {
|
||||
items.into_iter().any(|item| match item {
|
||||
MenuItemKind::Check(i) if i.id().as_ref() == id => {
|
||||
if i.is_checked().is_ok_and(|c| c != checked) {
|
||||
let _ = i.set_checked(checked);
|
||||
}
|
||||
true
|
||||
}
|
||||
MenuItemKind::Submenu(s) => s.items().is_ok_and(|children| walk(children, id, checked)),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
let Some(checked) = menu_state.rendered.lock().as_deref().and_then(|menus| rendered_check(menus, id)) else {
|
||||
return;
|
||||
};
|
||||
if let Some(menu) = app.menu()
|
||||
&& let Ok(items) = menu.items()
|
||||
{
|
||||
walk(items, id, checked);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the app menu from the current config and recent list.
|
||||
///
|
||||
/// The first install, and a change of the top-level menu ids, calls `set_menu`.
|
||||
@@ -1682,6 +1777,9 @@ mod tests {
|
||||
"window.toggleFullScreen",
|
||||
"view.toggleTheme",
|
||||
"editor.toggleZen",
|
||||
"view.toggleToolbar",
|
||||
"view.toggleToolWindowBars",
|
||||
"view.toggleStatusBar",
|
||||
"help.keymapReference",
|
||||
"help.showLog",
|
||||
"find.inFile",
|
||||
@@ -1761,7 +1859,8 @@ mod tests {
|
||||
Some(RenderedItem::Action {
|
||||
id: "file.save".into(),
|
||||
text: "Save All".into(),
|
||||
accel: Some("Meta+S".into())
|
||||
accel: Some("Meta+S".into()),
|
||||
checked: None,
|
||||
})
|
||||
);
|
||||
assert!(matches!(item("app.quit"), Some(RenderedItem::Action { accel: Some(a), .. }) if a == "CmdOrCtrl+Q"));
|
||||
@@ -1869,7 +1968,83 @@ mod tests {
|
||||
}
|
||||
|
||||
fn dynamic_item(action: &str, arg: Option<&str>, label: &str) -> DynamicItem {
|
||||
DynamicItem { action: action.into(), arg: arg.map(Into::into), label: label.into() }
|
||||
DynamicItem { action: action.into(), arg: arg.map(Into::into), label: label.into(), checked: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_toggles_are_checks_that_change_only_the_view_menu() {
|
||||
let entries = spec(Os::Mac);
|
||||
let base = MenuConfig::default();
|
||||
let checked = |config: &MenuConfig, id: &str| {
|
||||
let items: Vec<RenderedItem> =
|
||||
render_bar(&entries, config, &[]).into_iter().flat_map(|menu| menu.items).collect();
|
||||
match rendered_action(&items, id) {
|
||||
Some(RenderedItem::Action { checked, .. }) => *checked,
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
};
|
||||
for id in ["view.toggleCompactMode", "view.toggleToolbar", "view.toggleToolWindowBars", "view.toggleStatusBar"]
|
||||
{
|
||||
assert_eq!(checked(&base, id), Some(false), "{id}");
|
||||
}
|
||||
assert_eq!(checked(&base, "view.zoomIn"), None);
|
||||
|
||||
let mut on = base.clone();
|
||||
on.checked = vec!["view.toggleToolbar".into(), "view.zoomIn".into()];
|
||||
assert_eq!(checked(&on, "view.toggleToolbar"), Some(true));
|
||||
assert_eq!(checked(&on, "view.toggleStatusBar"), Some(false));
|
||||
assert_eq!(checked(&on, "view.zoomIn"), None, "plain items stay plain");
|
||||
assert_eq!(changed_menus(&render_bar(&entries, &base, &[]), &render_bar(&entries, &on, &[])), ["view"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clicked_check_returns_to_its_rendered_state() {
|
||||
let mut config = MenuConfig { checked: vec!["view.toggleToolbar".into()], ..MenuConfig::default() };
|
||||
config.dynamic.insert(
|
||||
DYNAMIC_STATUS_BAR_WIDGETS.into(),
|
||||
vec![DynamicItem {
|
||||
action: "view.toggleStatusBarWidget".into(),
|
||||
arg: Some("perf.memory".into()),
|
||||
label: "Memory Indicator".into(),
|
||||
checked: Some(false),
|
||||
}],
|
||||
);
|
||||
let menus = render_bar(&spec(Os::Mac), &config, &[]);
|
||||
assert_eq!(rendered_check(&menus, "view.toggleToolbar"), Some(true));
|
||||
assert_eq!(rendered_check(&menus, "view.toggleStatusBar"), Some(false));
|
||||
let widget = config.dynamic[DYNAMIC_STATUS_BAR_WIDGETS][0].item_id();
|
||||
assert_eq!(rendered_check(&menus, &widget), Some(false));
|
||||
assert_eq!(rendered_check(&menus, "view.zoomIn"), None, "plain items have no state");
|
||||
assert_eq!(rendered_check(&menus, "missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_widgets_are_listed_with_their_state() {
|
||||
let mut config = MenuConfig::default();
|
||||
config.dynamic.insert(
|
||||
DYNAMIC_STATUS_BAR_WIDGETS.into(),
|
||||
vec![DynamicItem {
|
||||
action: "view.toggleStatusBarWidget".into(),
|
||||
arg: Some("perf.memory".into()),
|
||||
label: "Memory Indicator".into(),
|
||||
checked: Some(true),
|
||||
}],
|
||||
);
|
||||
let id = config.dynamic[DYNAMIC_STATUS_BAR_WIDGETS][0].item_id();
|
||||
let items: Vec<RenderedItem> =
|
||||
render_bar(&spec(Os::Mac), &config, &[]).into_iter().flat_map(|menu| menu.items).collect();
|
||||
assert!(matches!(rendered_action(&items, &id), Some(RenderedItem::Action { checked: Some(true), .. })));
|
||||
assert_eq!(
|
||||
route(&spec(Os::Mac), &id),
|
||||
Some(MenuRoute { action: "view.toggleStatusBarWidget".into(), arg: Some("perf.memory".into()) })
|
||||
);
|
||||
|
||||
let spec =
|
||||
vec![sub("statusBarWidgets", "Status Bar Widgets", vec![Entry::Dynamic(DYNAMIC_STATUS_BAR_WIDGETS)])];
|
||||
let MenuNode::Submenu { children, .. } = &model(&spec, &[], &config)[0] else { panic!("not a submenu") };
|
||||
assert!(
|
||||
matches!(&children[0], MenuNode::Action { checked: Some(true), arg: Some(a), .. } if a == "perf.memory")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1900,7 +2075,11 @@ mod tests {
|
||||
let spec = spec(os);
|
||||
let mut found = Vec::new();
|
||||
slots(&spec, &mut found);
|
||||
assert_eq!(found, [DYNAMIC_TOOL_WINDOWS, DYNAMIC_PLUGIN_TOOLS, DYNAMIC_LAYOUTS], "{os:?}");
|
||||
assert_eq!(
|
||||
found,
|
||||
[DYNAMIC_TOOL_WINDOWS, DYNAMIC_STATUS_BAR_WIDGETS, DYNAMIC_PLUGIN_TOOLS, DYNAMIC_LAYOUTS],
|
||||
"{os:?}"
|
||||
);
|
||||
assert!(!action_ids(&spec).iter().any(|id| {
|
||||
id.starts_with("toolwindow.")
|
||||
&& *id != "toolwindow.hideAll"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Size the native title bar around the window buttons to match the web toolbar.
|
||||
//!
|
||||
//! An empty unified toolbar makes AppKit lay out and vertically centre the buttons itself, so
|
||||
//! nothing moves them behind its back and they stay still during live resize.
|
||||
//! nothing moves them behind its back and they stay still during live resize. A bar lower than
|
||||
//! the compact toolbar (IDEA's 30px compact header) keeps the plain title bar instead.
|
||||
//!
|
||||
//! In native full screen the same toolbar is drawn as an opaque strip over the web title bar, so
|
||||
//! it is hidden from the moment the window starts entering full screen until it has left (#1899).
|
||||
@@ -20,11 +21,28 @@ use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
|
||||
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
|
||||
use tauri::Manager;
|
||||
|
||||
/// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar.
|
||||
/// The native chrome around the window buttons: the plain title bar (28pt), or an empty unified
|
||||
/// compact (38pt) or unified (52pt) toolbar.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Chrome {
|
||||
Plain,
|
||||
Toolbar(NSWindowToolbarStyle),
|
||||
}
|
||||
|
||||
/// Midpoints between the 28, 38 and 52pt bars.
|
||||
const PLAIN_MAX_HEIGHT: f64 = 33.0;
|
||||
const COMPACT_MAX_HEIGHT: f64 = 45.0;
|
||||
|
||||
fn style_for(height: f64) -> NSWindowToolbarStyle {
|
||||
if height < COMPACT_MAX_HEIGHT { NSWindowToolbarStyle::UnifiedCompact } else { NSWindowToolbarStyle::Unified }
|
||||
/// The chrome closest to the web bar, so the buttons sit on its centre line (IDEA's 30px compact
|
||||
/// header takes the plain title bar).
|
||||
fn chrome_for(height: f64) -> Chrome {
|
||||
if height < PLAIN_MAX_HEIGHT {
|
||||
Chrome::Plain
|
||||
} else if height < COMPACT_MAX_HEIGHT {
|
||||
Chrome::Toolbar(NSWindowToolbarStyle::UnifiedCompact)
|
||||
} else {
|
||||
Chrome::Toolbar(NSWindowToolbarStyle::Unified)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a window is in, or on its way into, native full screen. `styleMask` cannot answer
|
||||
@@ -127,13 +145,23 @@ pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<(
|
||||
let native = &*webview.ns_window().cast::<NSWindow>();
|
||||
let state = WINDOWS
|
||||
.with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native, &app)).state.clone());
|
||||
if native.toolbar().is_none() {
|
||||
let toolbar = NSToolbar::new(mtm);
|
||||
native.setToolbar(Some(&toolbar));
|
||||
}
|
||||
let style = style_for(height);
|
||||
if native.toolbarStyle() != style {
|
||||
native.setToolbarStyle(style);
|
||||
match chrome_for(height) {
|
||||
Chrome::Plain => {
|
||||
if native.toolbar().is_some() {
|
||||
native.setToolbar(None);
|
||||
}
|
||||
}
|
||||
Chrome::Toolbar(style) => {
|
||||
if native.toolbar().is_none() {
|
||||
let toolbar = NSToolbar::new(mtm);
|
||||
// Hidden from the start in full screen, where it would paint an opaque strip.
|
||||
toolbar.setVisible(state.toolbar_visible());
|
||||
native.setToolbar(Some(&toolbar));
|
||||
}
|
||||
if native.toolbarStyle() != style {
|
||||
native.setToolbarStyle(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
apply(native, &state);
|
||||
})
|
||||
@@ -195,12 +223,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_style_follows_the_web_bar_height() {
|
||||
for (height, compact) in
|
||||
[(31.9, true), (32.0, true), (40.0, true), (44.9, true), (45.0, false), (52.0, false), (120.0, false)]
|
||||
{
|
||||
let expected = if compact { NSWindowToolbarStyle::UnifiedCompact } else { NSWindowToolbarStyle::Unified };
|
||||
assert_eq!(style_for(height), expected, "{height}");
|
||||
fn chrome_is_the_closest_to_the_web_bar_height() {
|
||||
use NSWindowToolbarStyle as Style;
|
||||
for (height, expected) in [
|
||||
(28.0, Chrome::Plain),
|
||||
(30.0, Chrome::Plain),
|
||||
(32.0, Chrome::Plain),
|
||||
(32.9, Chrome::Plain),
|
||||
(33.0, Chrome::Toolbar(Style::UnifiedCompact)),
|
||||
(38.0, Chrome::Toolbar(Style::UnifiedCompact)),
|
||||
(40.0, Chrome::Toolbar(Style::UnifiedCompact)),
|
||||
(44.9, Chrome::Toolbar(Style::UnifiedCompact)),
|
||||
(45.0, Chrome::Toolbar(Style::Unified)),
|
||||
(52.0, Chrome::Toolbar(Style::Unified)),
|
||||
(120.0, Chrome::Toolbar(Style::Unified)),
|
||||
] {
|
||||
assert_eq!(chrome_for(height), expected, "{height}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,12 @@ impl WindowsState {
|
||||
self.launches.lock().insert(label.to_owned(), launch);
|
||||
}
|
||||
|
||||
/// Whether the window `label` is created to open a project (local or remote).
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn opens_project(&self, label: &str) -> bool {
|
||||
self.launches.lock().get(label).is_some_and(|l| l.project.is_some() || l.remote.is_some())
|
||||
}
|
||||
|
||||
pub fn take_launch(&self, label: &str) -> Option<Launch> {
|
||||
let mut launch = self.launches.lock().remove(label)?;
|
||||
let mut positions = self.positions.lock();
|
||||
@@ -595,7 +601,7 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
|
||||
crate::page_swipe::install(&window);
|
||||
apply_zoom(app, &window);
|
||||
#[cfg(target_os = "macos")]
|
||||
align_titlebar(app, &window);
|
||||
align_titlebar(app, &window, app.try_state::<WindowsState>().is_some_and(|w| w.opens_project(window.label())));
|
||||
// "Sync with OS" needs the OS mode, known once the window exists.
|
||||
if let Some(a) = appearance.filter(|a| plugin_frame.is_none() && (a.sync_with_os || a.theme == "system"))
|
||||
&& window.theme().is_ok_and(|t| t == Theme::Dark)
|
||||
@@ -640,15 +646,29 @@ fn apply_zoom(app: &AppHandle, window: &WebviewWindow) {
|
||||
/// Aligns the native window buttons before the page reports its title bar height,
|
||||
/// so they don't visibly move once it loads.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn align_titlebar(app: &AppHandle, window: &WebviewWindow) {
|
||||
// `--h-titlebar` in tokens.css.
|
||||
const HEIGHT: f64 = 40.0;
|
||||
const COMPACT_HEIGHT: f64 = 32.0;
|
||||
fn align_titlebar(app: &AppHandle, window: &WebviewWindow, opens_project: bool) {
|
||||
let Some(a) = app.try_state::<AppState>().map(|s| s.global_settings.effective().appearance) else {
|
||||
return;
|
||||
};
|
||||
let height = if a.compact_mode { COMPACT_HEIGHT } else { HEIGHT };
|
||||
let _ = crate::titlebar::set_height(window, height * f64::from(a.zoom.clamp(50, 300)) / 100.0);
|
||||
let _ = crate::titlebar::set_height(window, initial_titlebar_height(&a, opens_project));
|
||||
}
|
||||
|
||||
/// The title bar height (AppKit points) the page will draw. Only a project window hides its
|
||||
/// toolbar for View › Appearance › Toolbar, and it is known to be one when it opens a project.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn initial_titlebar_height(a: &AppearanceSettings, opens_project: bool) -> f64 {
|
||||
// `--h-titlebar` and `--h-compact-header` in tokens.css.
|
||||
const HEIGHT: f64 = 40.0;
|
||||
const COMPACT_HEIGHT: f64 = 32.0;
|
||||
const COMPACT_HEADER_HEIGHT: f64 = 30.0;
|
||||
let height = if opens_project && !a.show_main_toolbar {
|
||||
COMPACT_HEADER_HEIGHT
|
||||
} else if a.compact_mode {
|
||||
COMPACT_HEIGHT
|
||||
} else {
|
||||
HEIGHT
|
||||
};
|
||||
height * f64::from(a.zoom.clamp(50, 300)) / 100.0
|
||||
}
|
||||
|
||||
/// The first window (`create: false` in tauri.conf.json so it can take the theme's colour).
|
||||
@@ -1290,7 +1310,7 @@ pub fn open_auxiliary_window(app: &AppHandle, caller: &WebviewWindow, aux: AuxWi
|
||||
crate::page_swipe::install(&window);
|
||||
apply_zoom(app, &window);
|
||||
#[cfg(target_os = "macos")]
|
||||
align_titlebar(app, &window);
|
||||
align_titlebar(app, &window, false);
|
||||
// Restore runs during build; fit its size to the parent's current display before showing.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Err(e) = fit_over_parent(&window, &parent, aux.min_size) {
|
||||
@@ -1523,6 +1543,20 @@ fn dev_font_preferences(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_project_window_starts_with_the_header_its_page_draws() {
|
||||
let mut a = AppearanceSettings::default();
|
||||
assert_eq!(initial_titlebar_height(&a, true), 40.0);
|
||||
a.compact_mode = true;
|
||||
assert_eq!(initial_titlebar_height(&a, true), 32.0);
|
||||
a.show_main_toolbar = false;
|
||||
assert_eq!(initial_titlebar_height(&a, true), 30.0);
|
||||
// Welcome, Settings and a window whose project comes later keep the full bar.
|
||||
assert_eq!(initial_titlebar_height(&a, false), 32.0);
|
||||
a.zoom = 150;
|
||||
assert_eq!(initial_titlebar_height(&a, true), 45.0);
|
||||
}
|
||||
|
||||
fn s(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|x| x.to_string()).collect()
|
||||
}
|
||||
|
||||
+14
-2
@@ -1911,6 +1911,9 @@ export type AppearanceSettings = {
|
||||
showToolWindowNames?: boolean,
|
||||
showToolWindowBadges?: boolean,
|
||||
widescreenToolWindows?: boolean,
|
||||
/** View › Appearance › Toolbar; hidden leaves IDEA's compact window header. */
|
||||
showMainToolbar?: boolean,
|
||||
showStatusBar?: boolean,
|
||||
/**
|
||||
* Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
* (status bar context menu); widgets not listed use their default.
|
||||
@@ -3548,6 +3551,8 @@ export type DynamicItem = {
|
||||
action: string,
|
||||
arg: string | null,
|
||||
label: string,
|
||||
/** Drawn with a check mark in this state; `None` for a plain item. */
|
||||
checked?: boolean | null,
|
||||
};
|
||||
|
||||
/** Editor › General › Appearance. */
|
||||
@@ -5705,6 +5710,8 @@ export type MenuConfig = {
|
||||
accelerators: { [key in string]: string },
|
||||
/** [`Entry::Dynamic`] slot → its items, already localised. */
|
||||
dynamic?: { [key in string]: DynamicItem[] },
|
||||
/** Ids of the toggle actions currently on. */
|
||||
checked?: string[],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -5712,8 +5719,13 @@ export type MenuConfig = {
|
||||
* Labels are the English defaults; the frontend localises them like the native menu.
|
||||
*/
|
||||
export type MenuNode = { kind: "submenu"; id: string; label: string; children: MenuNode[] } |
|
||||
/** Runs `id` with `arg`. Items with an `arg` (Open Recent) carry their final label. */
|
||||
{ kind: "action"; id: string; label: string; hint: string | null; arg: string | null } |
|
||||
/**
|
||||
* Runs `id` with `arg`. Items with an `arg` (Open Recent) carry their final label.
|
||||
* `checked` is the state of a frontend-listed toggle (Status Bar Widgets). Spec toggles
|
||||
* leave it unset: the frontend menu reads their state from the action (`toggled`), the
|
||||
* source [`MenuConfig::checked`] is built from for the native menu.
|
||||
*/
|
||||
{ kind: "action"; id: string; label: string; hint: string | null; arg: string | null; checked: boolean | null } |
|
||||
/** A disabled placeholder such as "No Recent Projects". */
|
||||
{ kind: "placeholder"; id: string; label: string } | { kind: "separator" };
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { _resetI18nForTests, setLocale } from "@/platform/i18n";
|
||||
import { useKeymap } from "@/platform/keymap";
|
||||
import * as ipc from "@/platform/ipc";
|
||||
import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree";
|
||||
import { registerToolWindow } from "@/workbench/contributions";
|
||||
import { registerStatusItem, registerToolWindow } from "@/workbench/contributions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { _reloadLayouts, saveLayout } from "@/workbench/toolwindows/layouts";
|
||||
import { clearContext, setContext } from "@/platform/context";
|
||||
import {
|
||||
@@ -29,7 +30,7 @@ describe("menu state from the frontend", () => {
|
||||
vi.spyOn(ipc.commands, "appBuildInfo").mockRejectedValue(new Error("No build info in tests"));
|
||||
vi.spyOn(ipc.commands, "menuConfigure").mockResolvedValue();
|
||||
vi.spyOn(ipc.commands, "menuModel").mockResolvedValue([
|
||||
{ kind: "action", id: "test.menuFocus", label: "Test", hint: null, arg: null },
|
||||
{ kind: "action", id: "test.menuFocus", label: "Test", hint: null, arg: null, checked: null },
|
||||
]);
|
||||
const pushDisabled = vi.spyOn(ipc.commands, "menuSetDisabled").mockResolvedValue();
|
||||
const offAction = registerAction({ id: "test.menuFocus", title: "Test", run: () => {} });
|
||||
@@ -84,11 +85,11 @@ describe("menu state from the frontend", () => {
|
||||
id: "x",
|
||||
label: "X",
|
||||
children: [
|
||||
{ kind: "action", id: "test.menu.whenEditor", label: "B", hint: null, arg: null },
|
||||
{ kind: "action", id: "test.menu.always", label: "A", hint: null, arg: null },
|
||||
{ kind: "action", id: "test.menu.whenEditor", label: "B", hint: null, arg: null, checked: null },
|
||||
{ kind: "action", id: "test.menu.always", label: "A", hint: null, arg: null, checked: null },
|
||||
],
|
||||
},
|
||||
{ kind: "action", id: "not.registered", label: "C", hint: null, arg: null },
|
||||
{ kind: "action", id: "not.registered", label: "C", hint: null, arg: null, checked: null },
|
||||
]);
|
||||
expect([...ids].sort()).toEqual(["not.registered", "test.menu.always", "test.menu.whenEditor"]);
|
||||
// Unregistered ids stay enabled: the action may register later (lazy features).
|
||||
@@ -206,3 +207,58 @@ describe("window-scoped menu labels", () => {
|
||||
resetDistractionFree();
|
||||
});
|
||||
});
|
||||
|
||||
describe("View › Appearance check marks", () => {
|
||||
it("sends the toggle actions that are on", () => {
|
||||
let on = true;
|
||||
const offs = [
|
||||
registerAction({ id: "test.menu.toggleOn", title: "On", toggled: () => on, run: () => {} }),
|
||||
registerAction({ id: "test.menu.toggleOff", title: "Off", toggled: () => false, run: () => {} }),
|
||||
];
|
||||
const checked = () => buildMenuConfig("macos").checked!.filter((id) => id.startsWith("test.menu."));
|
||||
expect(checked()).toEqual(["test.menu.toggleOn"]);
|
||||
on = false;
|
||||
expect(checked()).toEqual([]);
|
||||
offs.forEach((d) => d());
|
||||
});
|
||||
|
||||
it("lists titled status bar widgets in bar order with their visibility", () => {
|
||||
const widget = (id: string, align: "left" | "right", priority: number, title?: string, defaultVisible?: boolean) =>
|
||||
registerStatusItem({ id, align, priority, title, defaultVisible, component: () => null });
|
||||
const offs = [
|
||||
widget("w.encoding", "right", 2, "File Encoding"),
|
||||
widget("w.caret", "right", 1, "Line:Column Number"),
|
||||
widget("w.tasks", "left", 0),
|
||||
widget("w.memory", "right", 3, "Memory Indicator", false),
|
||||
widget("plugin.demo.w", "left", 1, "Demo Widget"),
|
||||
];
|
||||
useProject.setState({ settings: { appearance: { statusBarWidgets: { "w.caret": false } } } as never });
|
||||
expect(dynamicMenuItems().statusBarWidgets).toEqual([
|
||||
{ action: "view.toggleStatusBarWidget", arg: "plugin.demo.w", label: "Demo Widget", checked: true },
|
||||
{ action: "view.toggleStatusBarWidget", arg: "w.caret", label: "Line:Column Number", checked: false },
|
||||
{ action: "view.toggleStatusBarWidget", arg: "w.encoding", label: "File Encoding", checked: true },
|
||||
{ action: "view.toggleStatusBarWidget", arg: "w.memory", label: "Memory Indicator", checked: false },
|
||||
]);
|
||||
useProject.setState({ settings: null });
|
||||
offs.forEach((d) => d());
|
||||
});
|
||||
|
||||
it("lists only the widgets a LightEdit window's status bar has", () => {
|
||||
const offs = [
|
||||
registerStatusItem({
|
||||
id: "w.caret",
|
||||
align: "right",
|
||||
priority: 1,
|
||||
title: "Line:Column Number",
|
||||
lightEdit: true,
|
||||
component: () => null,
|
||||
}),
|
||||
registerStatusItem({ id: "w.branch", align: "right", priority: 2, title: "Git Branch", component: () => null }),
|
||||
];
|
||||
useProject.setState({ project: null, lightEdit: true });
|
||||
expect(dynamicMenuItems().statusBarWidgets!.map((i) => i.arg)).toEqual(["w.caret"]);
|
||||
useProject.setState({ lightEdit: false });
|
||||
expect(dynamicMenuItems().statusBarWidgets!.map((i) => i.arg)).toEqual(["w.caret", "w.branch"]);
|
||||
offs.forEach((d) => d());
|
||||
});
|
||||
});
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
useKeymap,
|
||||
} from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useContributions, useMenuSlotItems } from "@/workbench/contributions";
|
||||
import { useContributions, useMenuSlotItems, useStandaloneEditor } from "@/workbench/contributions";
|
||||
import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { orderStatusItems } from "@/workbench/StatusBar";
|
||||
import { statusWidgetRows } from "@/workbench/statusBarWidgets";
|
||||
import { useToolWindowLayouts } from "@/workbench/toolwindows/layouts";
|
||||
import { hostOs, type HostOs } from "./host";
|
||||
|
||||
@@ -77,10 +79,40 @@ export function buildMenuConfig(os: HostOs): MenuConfig {
|
||||
const accelerator = first && menuAccelerator(first);
|
||||
if (accelerator) accelerators[id] = accelerator;
|
||||
}
|
||||
return { locale: useI18n.getState().locale, labels, accelerators, dynamic: dynamicMenuItems() };
|
||||
return {
|
||||
locale: useI18n.getState().locale,
|
||||
labels,
|
||||
accelerators,
|
||||
dynamic: dynamicMenuItems(),
|
||||
checked: toggledActions(),
|
||||
};
|
||||
}
|
||||
|
||||
/** View › Tool Windows (every registered window, by title) and Window › Tool Window Layouts. */
|
||||
/** Toggle actions that are on: their menu items show a check mark (View › Appearance). */
|
||||
function toggledActions(): string[] {
|
||||
return [...useActions.getState().actions.values()]
|
||||
.filter((a) => a.toggled?.())
|
||||
.map((a) => a.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** View › Appearance › Status Bar Widgets: the status bar's context menu, in bar order. */
|
||||
function statusBarWidgetItems(): DynamicItem[] {
|
||||
const { project, lightEdit, settings } = useProject.getState();
|
||||
// A LightEdit window's status bar has only the items that apply there (Workbench.tsx).
|
||||
const lightEditFrame = !project && (lightEdit || useStandaloneEditor.getState().title !== null);
|
||||
const items = [...useContributions.getState().statusItems.values()].filter((i) => !lightEditFrame || i.lightEdit);
|
||||
const { left, right } = orderStatusItems(items);
|
||||
const overrides = settings?.appearance?.statusBarWidgets;
|
||||
return statusWidgetRows([...left, ...right], overrides).map((row) => ({
|
||||
action: "view.toggleStatusBarWidget",
|
||||
arg: row.id,
|
||||
label: row.verbatim ? row.title : tx(row.title),
|
||||
checked: row.visible,
|
||||
}));
|
||||
}
|
||||
|
||||
/** View › Tool Windows (every registered window, by title), View › Appearance › Status Bar Widgets and Window › Tool Window Layouts. */
|
||||
export function dynamicMenuItems(): Record<string, DynamicItem[]> {
|
||||
const toolWindows = [...useContributions.getState().toolWindows.values()]
|
||||
.map((tw) => ({ action: `toolwindow.${tw.id}`, arg: null, label: tw.verbatim ? tw.title : tx(tw.title) }))
|
||||
@@ -96,7 +128,7 @@ export function dynamicMenuItems(): Record<string, DynamicItem[]> {
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
]),
|
||||
);
|
||||
return { ...slots, toolWindows, toolWindowLayouts: layouts };
|
||||
return { ...slots, toolWindows, toolWindowLayouts: layouts, statusBarWidgets: statusBarWidgetItems() };
|
||||
}
|
||||
|
||||
/** Action ids of every IDE item in a menu model. */
|
||||
@@ -186,7 +218,7 @@ export function installMenuBridge(): () => void {
|
||||
if (s.active !== prev.active) push();
|
||||
}),
|
||||
useContributions.subscribe((s, prev) => {
|
||||
if (s.toolWindows !== prev.toolWindows) push();
|
||||
if (s.toolWindows !== prev.toolWindows || s.statusItems !== prev.statusItems) push();
|
||||
}),
|
||||
useToolWindowLayouts.subscribe(push),
|
||||
useMenuSlotItems.subscribe(push),
|
||||
@@ -195,6 +227,9 @@ export function installMenuBridge(): () => void {
|
||||
// Opening / closing a project changes the Open Recent list.
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) void commands.menuRefresh();
|
||||
if (s.lightEdit !== prev.lightEdit) push();
|
||||
// View › Appearance check marks and Status Bar Widgets.
|
||||
if (s.settings?.appearance !== prev.settings?.appearance) push();
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ActionContext } from "@/platform/actions";
|
||||
import type { Settings } from "@/platform/ipc";
|
||||
|
||||
const settingsSet = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
commands: { ...real.commands, settingsSet: (...a: unknown[]) => settingsSet(...a) },
|
||||
};
|
||||
});
|
||||
|
||||
import { useProject } from "@/platform/project";
|
||||
import { appearanceToggleActions } from "../appearanceRuntime";
|
||||
import { validateAppearanceExtras } from "../model/behaviorSchema";
|
||||
|
||||
let appearance: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
appearance = {};
|
||||
const settings = () => ({ appearance: { ...appearance } }) as unknown as Settings;
|
||||
useProject.setState({ settings: settings() });
|
||||
settingsSet.mockReset().mockImplementation(async (_scope: string, key: string, json: string) => {
|
||||
appearance[key.replace("appearance.", "")] = JSON.parse(json);
|
||||
return { status: "ok", data: settings() };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => useProject.setState({ settings: null }));
|
||||
|
||||
describe("View › Appearance toggles", () => {
|
||||
it("shows the toolbar and the status bar unless settings hide them", () => {
|
||||
expect(validateAppearanceExtras({})).toMatchObject({ showMainToolbar: true, showStatusBar: true });
|
||||
expect(validateAppearanceExtras({ showMainToolbar: false, showStatusBar: "no" })).toMatchObject({
|
||||
showMainToolbar: false,
|
||||
showStatusBar: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["view.toggleCompactMode", "compactMode", false],
|
||||
["view.toggleToolbar", "showMainToolbar", true],
|
||||
["view.toggleToolWindowBars", "showToolWindowBars", true],
|
||||
["view.toggleStatusBar", "showStatusBar", true],
|
||||
])("%s flips appearance.%s in the global settings", async (id, key, initial) => {
|
||||
const action = appearanceToggleActions().find((a) => a.id === id)!;
|
||||
expect(action.toggled?.()).toBe(initial);
|
||||
await action.run({ ctx: {} } as ActionContext);
|
||||
expect(settingsSet).toHaveBeenCalledWith("global", `appearance.${key}`, JSON.stringify(!initial));
|
||||
expect(action.toggled?.()).toBe(!initial);
|
||||
});
|
||||
});
|
||||
@@ -90,3 +90,10 @@ describe("page tree", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("indexes toolbar and status bar visibility in Appearance settings", () => {
|
||||
const appearance = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!;
|
||||
const labels = specLabels(appearance);
|
||||
expect(labels).toContain("Show main toolbar");
|
||||
expect(labels).toContain("Show status bar");
|
||||
});
|
||||
@@ -114,21 +114,43 @@ export function zoomActions(): Action[] {
|
||||
keywords: ["zoom", "scale", "100%", "accessibility"],
|
||||
run: () => setZoom(100),
|
||||
},
|
||||
{
|
||||
// View › Appearance › Compact Mode (Settings › Appearance › "Compact mode").
|
||||
id: "view.toggleCompactMode",
|
||||
title: "Compact Mode",
|
||||
category: "View",
|
||||
keywords: ["appearance", "density", "smaller"],
|
||||
toggled: () => validateSettings(useProject.getState().settings).appearance.compactMode,
|
||||
run: async () => {
|
||||
if (!isTauri()) return;
|
||||
const next = !validateSettings(useProject.getState().settings).appearance.compactMode;
|
||||
useProject.setState({
|
||||
settings: await call(commands.settingsSet("global", "appearance.compactMode", JSON.stringify(next))),
|
||||
});
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View › Appearance toggles over boolean appearance settings (IDEA's ToggleActions)
|
||||
|
||||
type AppearanceFlag = "compactMode" | "showMainToolbar" | "showToolWindowBars" | "showStatusBar";
|
||||
|
||||
const appearanceFlag = (key: AppearanceFlag) => validateSettings(useProject.getState().settings).appearance[key];
|
||||
|
||||
function appearanceToggle(id: string, title: string, key: AppearanceFlag, keywords: string[]): Action {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
category: "View",
|
||||
keywords: ["appearance", ...keywords],
|
||||
toggled: () => appearanceFlag(key),
|
||||
run: async () => {
|
||||
if (!isTauri()) return;
|
||||
const next = !appearanceFlag(key);
|
||||
useProject.setState({
|
||||
settings: await call(commands.settingsSet("global", `appearance.${key}`, JSON.stringify(next))),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function appearanceToggleActions(): Action[] {
|
||||
return [
|
||||
appearanceToggle("view.toggleCompactMode", "Compact Mode", "compactMode", ["density", "smaller"]),
|
||||
appearanceToggle("view.toggleToolbar", "Toolbar", "showMainToolbar", ["main toolbar", "header", "hide"]),
|
||||
appearanceToggle("view.toggleToolWindowBars", "Tool Window Bars", "showToolWindowBars", [
|
||||
"stripes",
|
||||
"tool window buttons",
|
||||
"hide",
|
||||
]),
|
||||
appearanceToggle("view.toggleStatusBar", "Status Bar", "showStatusBar", ["hide"]),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
settingsTitle,
|
||||
useSettingsDialog,
|
||||
} from "./model/staging";
|
||||
import { zoomActions } from "./appearanceRuntime";
|
||||
import { appearanceToggleActions, zoomActions } from "./appearanceRuntime";
|
||||
import { activateFileTypes } from "./fileTypesRuntime";
|
||||
import { trackRuntimeSettings } from "./runtime";
|
||||
import { installActionProxies, installSettingsHost } from "./window/bridge";
|
||||
@@ -123,6 +123,7 @@ export function activate(): () => void {
|
||||
registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }),
|
||||
registerOverlay({ id: "settings.projectStructure", component: LazyProjectStructureDialog }),
|
||||
registerActions(zoomActions()),
|
||||
registerActions(appearanceToggleActions()),
|
||||
registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))),
|
||||
registerActions([
|
||||
{
|
||||
|
||||
@@ -45,6 +45,9 @@ export interface AppearanceExtras {
|
||||
showToolWindowNames: boolean;
|
||||
showToolWindowBadges: boolean;
|
||||
widescreenToolWindows: boolean;
|
||||
/** View › Appearance › Toolbar; hidden leaves IDEA's compact window header. */
|
||||
showMainToolbar: boolean;
|
||||
showStatusBar: boolean;
|
||||
}
|
||||
|
||||
export interface DateFormatValid {
|
||||
@@ -105,6 +108,8 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
|
||||
showToolWindowNames: false,
|
||||
showToolWindowBadges: true,
|
||||
widescreenToolWindows: false,
|
||||
showMainToolbar: true,
|
||||
showStatusBar: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = {
|
||||
@@ -163,6 +168,8 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
|
||||
showToolWindowNames: b("showToolWindowNames"),
|
||||
showToolWindowBadges: b("showToolWindowBadges"),
|
||||
widescreenToolWindows: b("widescreenToolWindows"),
|
||||
showMainToolbar: b("showMainToolbar"),
|
||||
showStatusBar: b("showStatusBar"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -642,6 +642,8 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
|
||||
{
|
||||
title: "UI Options",
|
||||
options: [
|
||||
{ kind: "toggle", key: "appearance.showMainToolbar", label: "Show main toolbar" },
|
||||
{ kind: "toggle", key: "appearance.showStatusBar", label: "Show status bar" },
|
||||
{
|
||||
kind: "custom",
|
||||
label: "Main menu",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { branchesByRepo } from "./multiRoot";
|
||||
/** Title bar branch widget (branch name + incoming / outgoing markers) + Branches popup. */
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { create } from "zustand";
|
||||
import type { Branch, BranchInfo } from "@/bindings";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type MenuItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import type { PopupAnchor } from "@/ui/Popup";
|
||||
import { buildBranchRows, withExpanded, type BranchRow, type PopupAction } from "./branches";
|
||||
import { branchPresentation, extensionPopupActions, useBranchWidgetExtensions } from "./branchWidgetExtensions";
|
||||
import { branchMenu, commonBranchMenu, inRepo, tagMenu } from "./branchMenu";
|
||||
@@ -68,7 +69,6 @@ export function BranchWidget() {
|
||||
const otherOp = useVcs((s) => (current ? (s.operations[current] ?? null) : null));
|
||||
const open = useBranchesPopup((s) => s.open);
|
||||
const extensions = useBranchWidgetExtensions((s) => s.extensions);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
if (!available || !selected) return null;
|
||||
// Another feature presents the current branch (the GitHub pull request on it).
|
||||
const presented = other ? null : branchPresentation(extensions);
|
||||
@@ -98,7 +98,7 @@ export function BranchWidget() {
|
||||
return (
|
||||
<>
|
||||
<BarButton
|
||||
ref={ref}
|
||||
data-branches-widget
|
||||
variant="title"
|
||||
className={styles.widget}
|
||||
tooltip={tooltip}
|
||||
@@ -120,11 +120,17 @@ export function BranchWidget() {
|
||||
<BranchSync behind={branch.behind} ahead={branch.ahead} showCounts={false} />
|
||||
<Icon name="chevron-down" size={14} className={styles.chevron} />
|
||||
</BarButton>
|
||||
{open && <BranchesPopup anchor={ref} onClose={() => useBranchesPopup.setState({ open: false })} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchesPopupHost() {
|
||||
const open = useBranchesPopup((s) => s.open);
|
||||
if (!open) return null;
|
||||
const anchor = document.querySelector("[data-branches-widget]") ?? { x: window.innerWidth / 2 - 160, y: 60 };
|
||||
return <BranchesPopup anchor={anchor} onClose={() => useBranchesPopup.setState({ open: false })} />;
|
||||
}
|
||||
|
||||
const POPUP_ACTIONS: PopupAction[] = [
|
||||
{ id: "vcs.update", label: "Update Project…", icon: "git-pull", group: "sync" },
|
||||
{ id: "vcs.commit", label: "Commit…", icon: "commit", group: "sync" },
|
||||
@@ -283,7 +289,7 @@ const caretAt = (e: KeyboardEvent, where: "start" | "end") => {
|
||||
return where === "end" ? input.selectionStart === input.value.length : input.selectionStart === 0;
|
||||
};
|
||||
|
||||
function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | null>; onClose: () => void }) {
|
||||
function BranchesPopup({ anchor, onClose }: { anchor: PopupAnchor; onClose: () => void }) {
|
||||
const multi = useVcs((s) => s.roots.length > 1);
|
||||
const extensions = useBranchWidgetExtensions((s) => s.extensions);
|
||||
const actions = useMemo(() => [...POPUP_ACTIONS, ...extensionPopupActions(extensions)], [extensions]);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { BranchesPopupHost, showBranchesPopup, useBranchesPopup } from "../BranchWidget";
|
||||
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return { ...real, commands: { ...real.commands, vcsTags: async () => ({ status: "ok", data: [] }) } };
|
||||
});
|
||||
|
||||
afterEach(() => useBranchesPopup.setState({ open: false }));
|
||||
|
||||
it("opens Git Branches without a mounted toolbar widget", () => {
|
||||
render(<BranchesPopupHost />);
|
||||
act(showBranchesPopup);
|
||||
expect(screen.getByRole("dialog", { name: "Git Branches" })).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(useBranchesPopup.getState().open).toBe(false);
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import { registerLogPaneActions } from "./logPaneActions";
|
||||
import { controlLabel, controlOperation, inProgress } from "./operations";
|
||||
import { annotationsShown, blameSlot } from "./blame";
|
||||
import { inlineBlame } from "./inlineBlame";
|
||||
import { BranchWidget, useBranchesPopup } from "./BranchWidget";
|
||||
import { BranchWidget, BranchesPopupHost, useBranchesPopup } from "./BranchWidget";
|
||||
import { showConflictsDialog, takeContinueWhenResolved } from "./conflictsUi";
|
||||
import { registerCompareActions } from "./compare";
|
||||
import { DIFF_KIND, diffTitle, type DiffData } from "./diffInput";
|
||||
@@ -238,6 +238,7 @@ export function activate(): () => void {
|
||||
trackMergeRebaseItem(),
|
||||
installHunkPopup(),
|
||||
registerOverlay({ id: "vcs.dialogs", component: VcsDialogHost }),
|
||||
registerOverlay({ id: "vcs.branches", component: BranchesPopupHost }),
|
||||
activateConflictBanner(),
|
||||
activateConflictMarkers(),
|
||||
trackCommitWindow(),
|
||||
|
||||
@@ -394,6 +394,10 @@
|
||||
"menu.view.zoomOut": "Zoom Out IDE",
|
||||
"menu.view.resetZoom": "Reset IDE Zoom",
|
||||
"menu.toolbar.customize": "Customize Main Toolbar…",
|
||||
"menu.view.toggleToolbar": "Toolbar",
|
||||
"menu.view.toggleToolWindowBars": "Tool Window Bars",
|
||||
"menu.view.toggleStatusBar": "Status Bar",
|
||||
"menu.statusBarWidgets": "Status Bar Widgets",
|
||||
"menu.code.quickDefinition": "Quick Definition",
|
||||
"menu.code.quickTypeDefinition": "Quick Type Definition",
|
||||
"menu.localHistory.recentChanges": "Recent Changes",
|
||||
|
||||
@@ -552,5 +552,14 @@
|
||||
"No build": "No build",
|
||||
"Build, no error check": "Build, no error check",
|
||||
"Before launch build for this Java configuration": "Before launch build for this Java configuration",
|
||||
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`"
|
||||
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
|
||||
"Toolbar": "Toolbar",
|
||||
"Tool Window Bars": "Tool Window Bars",
|
||||
"Status Bar": "Status Bar",
|
||||
"Status Bar Widget": "Status Bar Widget",
|
||||
"Show main toolbar": "Show main toolbar",
|
||||
"Show status bar": "Show status bar",
|
||||
"No background tasks": "No background tasks",
|
||||
"Status Bar Widgets": "Status Bar Widgets",
|
||||
"Background Tasks": "Background Tasks"
|
||||
}
|
||||
@@ -3452,7 +3452,13 @@
|
||||
"Build images before starting (--build)": "启动前构建镜像 (--build)",
|
||||
"Up options": "Up 选项",
|
||||
"e.g. --force-recreate --remove-orphans": "例如 --force-recreate --remove-orphans",
|
||||
"Show main toolbar": "显示主工具栏",
|
||||
"Show status bar": "显示状态栏",
|
||||
"Compact Mode": "紧凑模式",
|
||||
"Toolbar": "工具栏",
|
||||
"Tool Window Bars": "工具窗口栏",
|
||||
"Status Bar": "状态栏",
|
||||
"Status Bar Widget": "状态栏微件",
|
||||
"Override File Type": "覆盖文件类型",
|
||||
"Revert File Type Override": "还原文件类型覆盖",
|
||||
"Add Content Root": "添加内容根",
|
||||
@@ -6896,5 +6902,7 @@
|
||||
"Servers": "服务器",
|
||||
"Open main menu with a lone Alt press": "单独按下 Alt 打开主菜单",
|
||||
"When off, Alt key combinations still work but pressing Alt alone does not focus the main menu": "关闭后,Alt 组合键仍然可用,但单独按下 Alt 不会聚焦主菜单",
|
||||
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "{gemfile} 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem \"debug\"`,然后运行 `bundle install`"
|
||||
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "{gemfile} 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem \"debug\"`,然后运行 `bundle install`",
|
||||
"No background tasks": "没有后台任务",
|
||||
"Status Bar Widgets": "状态栏微件"
|
||||
}
|
||||
@@ -394,6 +394,10 @@
|
||||
"menu.view.zoomOut": "缩小 IDE",
|
||||
"menu.view.resetZoom": "重置 IDE 缩放",
|
||||
"menu.toolbar.customize": "自定义主工具栏…",
|
||||
"menu.view.toggleToolbar": "工具栏",
|
||||
"menu.view.toggleToolWindowBars": "工具窗口栏",
|
||||
"menu.view.toggleStatusBar": "状态栏",
|
||||
"menu.statusBarWidgets": "状态栏微件",
|
||||
"menu.code.quickDefinition": "快速定义",
|
||||
"menu.code.quickTypeDefinition": "快速类型定义",
|
||||
"menu.localHistory.recentChanges": "最近的更改",
|
||||
|
||||
@@ -257,6 +257,8 @@ html {
|
||||
|
||||
/* metrics (theme JSON at 100%) */
|
||||
--h-titlebar: 40px;
|
||||
/* IDEA's compact header (HEADER_HEIGHT_DFM), the same in compact mode. */
|
||||
--h-compact-header: 30px;
|
||||
--titlebar-tool-button: 30px;
|
||||
/* tool window stripe button, icons only */
|
||||
--stripe-icon-button: 30px;
|
||||
|
||||
@@ -31,6 +31,8 @@ export type MenuItem =
|
||||
action: string;
|
||||
arg?: unknown;
|
||||
label?: string;
|
||||
/** A check mark in this state instead of the action's own `toggled` (one row per argument). */
|
||||
checked?: boolean;
|
||||
icon?: IconName | (string & {});
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -82,12 +84,12 @@ export function resolveMenuItems(items: MenuItem[]): Resolved[] {
|
||||
if (!a) return;
|
||||
out.push({
|
||||
kind: "item",
|
||||
key: item.action,
|
||||
key: typeof item.arg === "string" ? `${item.action}|${item.arg}` : item.action,
|
||||
label: tx(item.label) ?? a.title,
|
||||
icon: item.icon ?? a.icon,
|
||||
shortcut: shortcutLabel(item.action),
|
||||
disabled: !!item.disabled || !isActionEnabled(a),
|
||||
checked: actionToggled(a),
|
||||
checked: item.checked ?? actionToggled(a),
|
||||
run: () => void executeActionFrom("menu", item.action, item.arg),
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { commands, type Settings } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { MainMenu } from "./MainMenu";
|
||||
|
||||
let available = 500;
|
||||
@@ -35,13 +36,13 @@ beforeEach(() => {
|
||||
kind: "submenu",
|
||||
id: "file",
|
||||
label: "File",
|
||||
children: [{ kind: "action", id: "test.open", label: "Open", hint: null, arg: null }],
|
||||
children: [{ kind: "action", id: "test.open", label: "Open", hint: null, arg: null, checked: null }],
|
||||
},
|
||||
{
|
||||
kind: "submenu",
|
||||
id: "edit",
|
||||
label: "Edit",
|
||||
children: [{ kind: "action", id: "test.undo", label: "Undo", hint: null, arg: null }],
|
||||
children: [{ kind: "action", id: "test.undo", label: "Undo", hint: null, arg: null, checked: null }],
|
||||
},
|
||||
]);
|
||||
dispose = registerActions([
|
||||
@@ -267,3 +268,39 @@ describe("main menu presentation", () => {
|
||||
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["windows", "linux"] as const)("renders toggle and widget checks in the %s main menu", async (os) => {
|
||||
const platform = usePlatform.getState();
|
||||
usePlatform.setState({ os, framelessTitleBar: true, overlayTitleBar: false });
|
||||
const off = registerActions([
|
||||
{ id: "view.toggleToolbar", title: "Toolbar", toggled: () => true, run: () => {} },
|
||||
{ id: "view.toggleStatusBarWidget", title: "Status Bar Widget", run: () => {} },
|
||||
]);
|
||||
vi.mocked(commands.menuModel).mockResolvedValue([
|
||||
{
|
||||
kind: "submenu",
|
||||
id: "view",
|
||||
label: "View",
|
||||
children: [
|
||||
{ kind: "action", id: "view.toggleToolbar", label: "Toolbar", hint: null, arg: null, checked: null },
|
||||
{
|
||||
kind: "action",
|
||||
id: "view.toggleStatusBarWidget",
|
||||
label: "Memory Indicator",
|
||||
hint: null,
|
||||
arg: "memory",
|
||||
checked: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
try {
|
||||
render(<MainMenu />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
|
||||
expect(await screen.findByRole("menuitemcheckbox", { name: "Toolbar" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByRole("menuitemcheckbox", { name: "Memory Indicator" })).toHaveAttribute("aria-checked", "false");
|
||||
} finally {
|
||||
off();
|
||||
usePlatform.setState(platform);
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { registerAction } from "@/platform/actions";
|
||||
import { resolveMenuItems } from "@/ui/Menu";
|
||||
import { menuItems, onLoneAlt } from "./MainMenu";
|
||||
|
||||
describe("lone Alt opens the main menu", () => {
|
||||
@@ -55,7 +57,7 @@ describe("lone Alt opens the main menu", () => {
|
||||
describe("main menu model", () => {
|
||||
it("maps menu nodes to menu items, localising spec labels only", () => {
|
||||
const items = menuItems([
|
||||
{ kind: "action", id: "file.save", label: "Save All", hint: null, arg: null },
|
||||
{ kind: "action", id: "file.save", label: "Save All", hint: null, arg: null, checked: null },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
kind: "submenu",
|
||||
@@ -68,6 +70,7 @@ describe("main menu model", () => {
|
||||
label: "openidea — ~/openidea",
|
||||
hint: null,
|
||||
arg: "/p/openidea",
|
||||
checked: null,
|
||||
},
|
||||
{ kind: "placeholder", id: "recent.none", label: "No Recent Projects" },
|
||||
],
|
||||
@@ -86,3 +89,20 @@ describe("main menu model", () => {
|
||||
expect(sub[1]).toMatchObject({ label: "No Recent Projects", disabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("check marks in the main menu", () => {
|
||||
it("shows a frontend-listed item's own state, one row per argument", () => {
|
||||
const off = registerAction({ id: "test.mainMenu.widget", title: "Widget", run: () => {} });
|
||||
const node = (arg: string, checked: boolean) =>
|
||||
({ kind: "action", id: "test.mainMenu.widget", label: arg, hint: null, arg, checked }) as const;
|
||||
const items = menuItems([node("memory", true), node("caret", false)]);
|
||||
expect(items[0]).toMatchObject({ action: "test.mainMenu.widget", arg: "memory", checked: true });
|
||||
const rows = resolveMenuItems(items);
|
||||
expect(rows.map((r) => [r.label, r.checked])).toEqual([
|
||||
["memory", true],
|
||||
["caret", false],
|
||||
]);
|
||||
expect(new Set(rows.map((r) => r.key)).size).toBe(2);
|
||||
off();
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,13 @@ export function menuItems(nodes: MenuNode[]): MenuItem[] {
|
||||
case "submenu":
|
||||
return { id: n.id, label: label(n.id, n.label), submenu: menuItems(n.children) };
|
||||
case "action":
|
||||
return { kind: "action", action: n.id, arg: n.arg ?? undefined, label: n.arg ? n.label : label(n.id, n.label) };
|
||||
return {
|
||||
kind: "action",
|
||||
action: n.id,
|
||||
arg: n.arg ?? undefined,
|
||||
label: n.arg ? n.label : label(n.id, n.label),
|
||||
...(n.checked != null && { checked: n.checked }),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { useMemo } from "react";
|
||||
import clsx from "clsx";
|
||||
import { BarButton, IconButton, Popup, ProgressBar, showContextMenu, T, tooltipAttrs } from "@/ui";
|
||||
import { useContributions, useStatusInfo, type StatusItemContribution } from "./contributions";
|
||||
@@ -86,11 +87,16 @@ function TaskCancel({ task: t }: { task: BackgroundTask }) {
|
||||
);
|
||||
}
|
||||
|
||||
const useTaskPopup = create<{ open: boolean }>(() => ({ open: false }));
|
||||
|
||||
export function showBackgroundTasks() {
|
||||
useTaskPopup.setState({ open: true });
|
||||
}
|
||||
|
||||
/** Built-in: background task progress; click lists all running tasks. */
|
||||
export function TaskStatus() {
|
||||
const tasks = useTasks((s) => s.tasks);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const open = useTaskPopup((s) => s.open);
|
||||
const running = tasks.filter((t) => !t.done);
|
||||
// A task that is working wins over the queued ones waiting behind it.
|
||||
const current = running.findLast((t) => !t.queued) ?? running[running.length - 1] ?? tasks[tasks.length - 1];
|
||||
@@ -99,11 +105,11 @@ export function TaskStatus() {
|
||||
return (
|
||||
<>
|
||||
<BarButton
|
||||
ref={ref}
|
||||
data-background-tasks
|
||||
aria-expanded={open}
|
||||
aria-label={tx`Background tasks: ${current.title}`}
|
||||
tooltip={current.message ?? current.title}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={() => useTaskPopup.setState({ open: !open })}
|
||||
className={styles.task}
|
||||
>
|
||||
<span className={styles.taskTitle}>
|
||||
@@ -116,33 +122,49 @@ export function TaskStatus() {
|
||||
/>
|
||||
{more > 0 && <span className={styles.taskMore}>+{more}</span>}
|
||||
</BarButton>
|
||||
<Popup
|
||||
open={open && tasks.length > 0}
|
||||
anchor={ref}
|
||||
placement="top-end"
|
||||
offset={6}
|
||||
onClose={() => setOpen(false)}
|
||||
aria-label="Background tasks"
|
||||
>
|
||||
<div className={styles.taskList}>
|
||||
<div className={styles.taskListHeader}>
|
||||
<T>Background Tasks</T>
|
||||
</div>
|
||||
{tasks.map((t) => (
|
||||
<div key={t.id} className={styles.taskRow}>
|
||||
<div className={styles.taskRowHead}>
|
||||
<span className={styles.taskRowTitle}>
|
||||
<T>{t.title}</T>
|
||||
</span>
|
||||
{t.fraction != null && <span className={styles.taskPct}>{Math.round(t.fraction * 100)}%</span>}
|
||||
<TaskCancel task={t} />
|
||||
</div>
|
||||
<ProgressBar value={t.done ? 1 : t.fraction} aria-label={t.title} />
|
||||
{t.message && <div className={styles.taskMessage}>{t.message}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundTasksHost() {
|
||||
const open = useTaskPopup((s) => s.open);
|
||||
const tasks = useTasks((s) => s.tasks);
|
||||
const anchor = document.querySelector("[data-background-tasks]") ?? {
|
||||
x: window.innerWidth - 16,
|
||||
y: window.innerHeight - 16,
|
||||
};
|
||||
return (
|
||||
<Popup
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
placement="top-end"
|
||||
offset={6}
|
||||
onClose={() => useTaskPopup.setState({ open: false })}
|
||||
aria-label="Background tasks"
|
||||
>
|
||||
<div className={styles.taskList}>
|
||||
<div className={styles.taskListHeader}>
|
||||
<T>Background Tasks</T>
|
||||
</div>
|
||||
{tasks.length === 0 && (
|
||||
<div className={styles.taskRow}>
|
||||
<T>No background tasks</T>
|
||||
</div>
|
||||
)}
|
||||
{tasks.map((t) => (
|
||||
<div key={t.id} className={styles.taskRow}>
|
||||
<div className={styles.taskRowHead}>
|
||||
<span className={styles.taskRowTitle}>
|
||||
<T>{t.title}</T>
|
||||
</span>
|
||||
{t.fraction != null && <span className={styles.taskPct}>{Math.round(t.fraction * 100)}%</span>}
|
||||
<TaskCancel task={t} />
|
||||
</div>
|
||||
<ProgressBar value={t.done ? 1 : t.fraction} aria-label={t.title} />
|
||||
{t.message && <div className={styles.taskMessage}>{t.message}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,26 @@
|
||||
background: var(--bg-dialog, var(--bg-elev));
|
||||
box-shadow: none;
|
||||
}
|
||||
/* IDEA's compact header (Distraction Free, toolbar hidden): the window title, centred. */
|
||||
.compactHeader {
|
||||
--h-titlebar: var(--h-compact-header);
|
||||
}
|
||||
.overlay.compactHeader {
|
||||
padding-right: max(82px, calc(82px / var(--titlebar-zoom, 1)));
|
||||
}
|
||||
.overlayFullscreen.compactHeader {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.compactTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
.windowTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -163,3 +163,24 @@ describe("merged title bar menu", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("compact window header", () => {
|
||||
it("shows the window title and follows it, without toolbar widgets", async () => {
|
||||
document.title = "demo – src/main.rs";
|
||||
render(<TitleBar compact />);
|
||||
const bar = document.querySelector("header[data-titlebar='compact']")!;
|
||||
expect(bar.textContent).toBe("demo – src/main.rs");
|
||||
expect(bar.querySelector("button")).toBeNull();
|
||||
await act(async () => {
|
||||
document.title = "demo – Cargo.toml";
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(bar.textContent).toBe("demo – Cargo.toml");
|
||||
});
|
||||
|
||||
it("prefers an explicit title (LightEdit)", () => {
|
||||
document.title = "ignored";
|
||||
render(<TitleBar compact lightEdit title="notes.txt" />);
|
||||
expect(document.querySelector("header[data-titlebar='compact']")!.textContent).toBe("notes.txt");
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { useContributedThemes } from "@/platform/themes";
|
||||
import { currentThemeChoice, useThemeOverride } from "./theme";
|
||||
import styles from "./TitleBar.module.css";
|
||||
import { useNativeTitlebar } from "./nativeTitlebar";
|
||||
import { useDocumentTitle } from "./documentTitle";
|
||||
import { windowDragRegion } from "./window-drag";
|
||||
|
||||
export function orderTitleItems(items: Iterable<TitleItemContribution>) {
|
||||
@@ -271,21 +272,35 @@ function ToolbarEntry({ id, items }: { id: string; items: ReadonlyMap<string, Ti
|
||||
);
|
||||
}
|
||||
|
||||
/** IDEA's compact header: the window title in the middle of a 30px bar. */
|
||||
function CompactTitle({ title }: { title?: string }) {
|
||||
const documentTitle = useDocumentTitle();
|
||||
return (
|
||||
<span className={styles.compactTitle} {...windowDragRegion}>
|
||||
{title ?? documentTitle}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 40px main toolbar. macOS: native traffic lights overlay its left edge.
|
||||
* Windows and Linux: the window is frameless, so the bar carries IDEA's main menu (☰) and
|
||||
* the caption buttons. `dialog`: a dialog window's bar (Settings), close button only.
|
||||
* `lightEdit`: a LightEdit window's bar, its title and the menu but no toolbar widgets.
|
||||
* `compact`: IDEA's compact header (Distraction Free, View › Appearance › Toolbar off).
|
||||
* `bare`: the window frame only, while a project opens.
|
||||
*/
|
||||
export function TitleBar({
|
||||
minimal = false,
|
||||
bare = false,
|
||||
compact = false,
|
||||
dialog = false,
|
||||
lightEdit = false,
|
||||
title,
|
||||
}: {
|
||||
minimal?: boolean;
|
||||
bare?: boolean;
|
||||
compact?: boolean;
|
||||
dialog?: boolean;
|
||||
lightEdit?: boolean;
|
||||
title?: string;
|
||||
@@ -303,18 +318,19 @@ export function TitleBar({
|
||||
overlay && fullscreen && styles.overlayFullscreen,
|
||||
frameless && styles.frameless,
|
||||
);
|
||||
// Distraction Free Mode: the window frame only. Where the title bar carries the
|
||||
// main menu (Windows, Linux) it stays, or nothing would lead back out of the mode.
|
||||
if (bare)
|
||||
// Where the title bar carries the main menu (Windows, Linux) it stays, or nothing
|
||||
// would lead back out of Distraction Free Mode or bring the toolbar back.
|
||||
if (bare || compact)
|
||||
return (
|
||||
<header ref={setBar} className={className} {...windowDragRegion} data-titlebar="bare">
|
||||
{frameless && (
|
||||
<>
|
||||
<MainMenu />
|
||||
<div className={styles.drag} {...windowDragRegion} />
|
||||
<WindowControls />
|
||||
</>
|
||||
)}
|
||||
<header
|
||||
ref={setBar}
|
||||
className={clsx(className, compact && styles.compactHeader)}
|
||||
{...windowDragRegion}
|
||||
data-titlebar={compact ? "compact" : "bare"}
|
||||
>
|
||||
{frameless && <MainMenu />}
|
||||
{compact ? <CompactTitle title={title} /> : frameless && <div className={styles.drag} {...windowDragRegion} />}
|
||||
{frameless && <WindowControls />}
|
||||
</header>
|
||||
);
|
||||
const entry = (id: string) => <ToolbarEntry key={id} id={id} items={map} />;
|
||||
|
||||
@@ -23,19 +23,25 @@ import { WindowResizeHandles } from "./WindowResizeHandles";
|
||||
import { useContributions, useStandaloneEditor } from "./contributions";
|
||||
import styles from "./Workbench.module.css";
|
||||
|
||||
/** View › Appearance switches (`appearance.showMainToolbar`, `appearance.showStatusBar`), shown by default. */
|
||||
function useAppearanceFlag(key: "showMainToolbar" | "showStatusBar"): boolean {
|
||||
return useProject((s) => s.settings?.appearance?.[key] !== false);
|
||||
}
|
||||
|
||||
/** LightEdit: editor tabs only; no tool windows, toolbar widgets or project status items. */
|
||||
function LightEditFrame({ title }: { title?: string }) {
|
||||
const path = useEditorRegistry((s) => s.active?.ctx.path);
|
||||
const distractionFree = useDistractionFree((s) => s.active);
|
||||
const statusBar = useAppearanceFlag("showStatusBar");
|
||||
return (
|
||||
<>
|
||||
<TitleBar lightEdit bare={distractionFree} title={title ?? (path ? basename(path) : "LightEdit")} />
|
||||
<TitleBar lightEdit compact={distractionFree} title={title ?? (path ? basename(path) : "LightEdit")} />
|
||||
<main className={styles.lightEdit} data-light-edit>
|
||||
<div className={styles.lightEditIsland}>
|
||||
<EditorArea />
|
||||
</div>
|
||||
</main>
|
||||
{!distractionFree && <StatusBar lightEdit />}
|
||||
{!distractionFree && statusBar && <StatusBar lightEdit />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +76,8 @@ export function Workbench() {
|
||||
const openingPath = useProject((s) => s.openingPath);
|
||||
const root = project?.root ?? null;
|
||||
const distractionFree = useDistractionFree((s) => s.active);
|
||||
const mainToolbar = useAppearanceFlag("showMainToolbar");
|
||||
const statusBar = useAppearanceFlag("showStatusBar");
|
||||
const frame = project ? styles.framed : lightEdit || standaloneTitle ? styles.lightEditFrame : undefined;
|
||||
|
||||
return (
|
||||
@@ -80,14 +88,13 @@ export function Workbench() {
|
||||
<div className={styles.content} inert={openingPath !== null} aria-hidden={openingPath ? true : undefined}>
|
||||
{project ? (
|
||||
<>
|
||||
{/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */}
|
||||
<TitleBar bare={distractionFree} />
|
||||
<TitleBar compact={distractionFree || !mainToolbar} />
|
||||
{!distractionFree && <WindowGroupTabs />}
|
||||
{[...banners.values()].map(({ id, component: Banner }) => (
|
||||
<Banner key={id} />
|
||||
))}
|
||||
<ToolWindowLayout key={root} editor={<EditorArea />} />
|
||||
{!distractionFree && <StatusBar />}
|
||||
{!distractionFree && statusBar && <StatusBar />}
|
||||
</>
|
||||
) : lightEdit || standaloneTitle ? (
|
||||
<LightEditFrame title={standaloneTitle ?? undefined} />
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { loadPlatformInfo } from "@/platform/platformInfo";
|
||||
import { registerShellActions } from "./shellActions";
|
||||
import { MainToolbarDialogHost } from "./MainToolbarDialog";
|
||||
import { StatusInfo, TaskStatus } from "./StatusBar";
|
||||
import { BackgroundTasksHost, StatusInfo, TaskStatus } from "./StatusBar";
|
||||
import { installTaskEvents } from "./tasks";
|
||||
import { installToolWindowActions } from "./toolwindows/actions";
|
||||
import {
|
||||
@@ -67,6 +67,7 @@ export function activateWorkbench(): () => void {
|
||||
installBrowserGuards(),
|
||||
installFileDrop(),
|
||||
registerOverlay({ id: "workbench.customizeToolbar", component: MainToolbarDialogHost }),
|
||||
registerOverlay({ id: "workbench.backgroundTasks", component: BackgroundTasksHost }),
|
||||
registerStatusItem({ id: "workbench.tasks", align: "right", priority: 0, component: TaskStatus, lightEdit: true }),
|
||||
registerStatusItem({ id: "workbench.info", align: "left", priority: 5, component: StatusInfo, lightEdit: true }),
|
||||
registerToolWindow({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, render, screen } from "@testing-library/react";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { bindingsFor, installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||
import type { Settings } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerStatusItem, registerToolWindow } from "./contributions";
|
||||
import { clearNotifications, useNotifications } from "./notificationStore";
|
||||
@@ -216,7 +217,7 @@ describe("distraction free shell", () => {
|
||||
expect(screen.queryByText("status-x")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Project" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Search Everywhere" })).toBeNull();
|
||||
const bar = document.querySelector("header[data-titlebar='bare']");
|
||||
const bar = document.querySelector("header[data-titlebar='compact']");
|
||||
// macOS uses its own drag region attribute (window-drag.ts); jsdom reports the host OS.
|
||||
expect(bar?.hasAttribute("data-tauri-drag-region") || bar?.hasAttribute("data-window-drag-region")).toBe(true);
|
||||
|
||||
@@ -226,4 +227,33 @@ describe("distraction free shell", () => {
|
||||
unmount();
|
||||
useProject.setState({ project: null });
|
||||
});
|
||||
|
||||
it("View › Appearance hides the main toolbar and the status bar on their own", async () => {
|
||||
const { Workbench } = await import("./Workbench");
|
||||
disposers.push(
|
||||
registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => <span>status-x</span> }),
|
||||
);
|
||||
const appearance = (a: Record<string, boolean>) =>
|
||||
act(() => useProject.setState({ settings: { appearance: a } as unknown as Settings }));
|
||||
act(() =>
|
||||
useProject.setState({
|
||||
project: { root: "/work/df", name: "df-demo", trusted: true, isGit: false, settings: {} },
|
||||
}),
|
||||
);
|
||||
document.title = "df-demo – main.rs";
|
||||
const { unmount } = render(<Workbench />);
|
||||
expect(await screen.findByText("df-demo")).toBeInTheDocument();
|
||||
|
||||
appearance({ showMainToolbar: false });
|
||||
expect(screen.queryByText("df-demo")).toBeNull();
|
||||
expect(document.querySelector("header[data-titlebar='compact']")?.textContent).toBe("df-demo – main.rs");
|
||||
expect(screen.getByText("status-x")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Project" })).toBeInTheDocument();
|
||||
|
||||
appearance({ showStatusBar: false });
|
||||
expect(screen.getByText("df-demo")).toBeInTheDocument();
|
||||
expect(screen.queryByText("status-x")).toBeNull();
|
||||
unmount();
|
||||
useProject.setState({ project: null, settings: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
function subscribe(changed: () => void): () => void {
|
||||
const observer = new MutationObserver(changed);
|
||||
observer.observe(document.head, { subtree: true, childList: true, characterData: true });
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
/** The window title as `features/app/title.ts` writes it to `document.title`. */
|
||||
export function useDocumentTitle(): string {
|
||||
return useSyncExternalStore(subscribe, () => document.title);
|
||||
}
|
||||
@@ -95,6 +95,18 @@ describe("native title bar alignment", () => {
|
||||
expect(disconnect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("hands the window buttons back to the remaining bar when another bar goes away", () => {
|
||||
zoom(100);
|
||||
renderHook(() => useNativeTitlebar(titlebar(30).bar));
|
||||
expect(setHeight).toHaveBeenLastCalledWith(30);
|
||||
// The project-opening screen covers the frame with its own 40px bar.
|
||||
const opening = renderHook(() => useNativeTitlebar(titlebar(40).bar));
|
||||
expect(setHeight).toHaveBeenLastCalledWith(40);
|
||||
opening.unmount();
|
||||
expect(setHeight).toHaveBeenLastCalledWith(30);
|
||||
expect(setHeight).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("leaves windows without native overlay buttons alone", () => {
|
||||
usePlatform.setState({ overlayTitleBar: false });
|
||||
const { bar } = titlebar(40);
|
||||
|
||||
@@ -4,6 +4,13 @@ import { usePlatform } from "@/platform/platformInfo";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { clampZoom } from "@/platform/zoom";
|
||||
|
||||
/**
|
||||
* Mounted bars, newest last, each with a resend that ignores what it sent before. The window
|
||||
* has one set of buttons: when a bar covering another goes away (the project-opening screen
|
||||
* over the frame), the one left re-aligns them to its own height.
|
||||
*/
|
||||
const mounted: Array<() => void> = [];
|
||||
|
||||
export function useNativeTitlebar(bar: HTMLElement | null) {
|
||||
const overlay = usePlatform((s) => s.overlayTitleBar);
|
||||
const zoom = useProject((s) => (s.settings ? clampZoom(s.settings.appearance?.zoom) : null));
|
||||
@@ -25,9 +32,16 @@ export function useNativeTitlebar(bar: HTMLElement | null) {
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(bar);
|
||||
const resend = () => {
|
||||
lastHeight.current = null;
|
||||
update();
|
||||
};
|
||||
mounted.push(resend);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
bar.style.removeProperty("--titlebar-zoom");
|
||||
mounted.splice(mounted.indexOf(resend), 1);
|
||||
mounted.at(-1)?.();
|
||||
};
|
||||
}, [bar, overlay, zoom]);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { showBackgroundTasks } from "./StatusBar";
|
||||
import { executeAction, getAction, registerActions } from "@/platform/actions";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { closeProject, confirmLeaveProject, openProject, useProject } from "@/platform/project";
|
||||
@@ -10,6 +11,7 @@ import { activateToolWindow } from "./toolwindows/store";
|
||||
import { toggleTheme } from "./theme";
|
||||
import { cancelTask, canCancel, useTasks } from "./tasks";
|
||||
import { maybeEditorService, showStatusInfo } from "./contributions";
|
||||
import { toggleStatusWidget } from "./statusBarWidgets";
|
||||
|
||||
export async function openProjectDialog() {
|
||||
if (!isTauri()) {
|
||||
@@ -105,6 +107,21 @@ export function registerShellActions(): () => void {
|
||||
keywords: ["toolbar", "widgets", "title bar"],
|
||||
run: showCustomizeToolbar,
|
||||
},
|
||||
{
|
||||
// View › Appearance › Status Bar Widgets: the argument is the widget id.
|
||||
id: "view.toggleStatusBarWidget",
|
||||
title: "Status Bar Widget",
|
||||
category: "View",
|
||||
hidden: true,
|
||||
run: ({ arg }) => (typeof arg === "string" ? toggleStatusWidget(arg) : undefined),
|
||||
},
|
||||
{
|
||||
id: "view.backgroundTasks",
|
||||
title: "Background Tasks",
|
||||
category: "View",
|
||||
keywords: ["progress", "running", "processes"],
|
||||
run: showBackgroundTasks,
|
||||
},
|
||||
{
|
||||
id: "view.notifications",
|
||||
title: "Notifications",
|
||||
|
||||
@@ -17,6 +17,8 @@ import { ContextMenuHost } from "@/ui";
|
||||
import { registerStatusItem, type StatusItemContribution } from "./contributions";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { isStatusItemVisible, statusBarMenu } from "./statusBarWidgets";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { registerShellActions } from "./shellActions";
|
||||
|
||||
const item = (id: string, extra: Partial<StatusItemContribution> = {}): StatusItemContribution => ({
|
||||
id,
|
||||
@@ -103,4 +105,25 @@ describe("status bar widgets", () => {
|
||||
);
|
||||
expect(await screen.findByText("w.memory")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles one widget by id from View › Appearance › Status Bar Widgets", async () => {
|
||||
disposers.push(
|
||||
registerShellActions(),
|
||||
registerStatusItem(item("w.caret", { title: "Line:Column Number" })),
|
||||
registerStatusItem(item("w.memory", { title: "Memory Indicator", defaultVisible: false })),
|
||||
registerStatusItem(item("w.tasks")),
|
||||
);
|
||||
await act(() => executeAction("view.toggleStatusBarWidget", "w.memory"));
|
||||
expect(settingsSet).toHaveBeenLastCalledWith(
|
||||
"global",
|
||||
"appearance.statusBarWidgets",
|
||||
JSON.stringify({ "w.memory": true }),
|
||||
);
|
||||
await act(() => executeAction("view.toggleStatusBarWidget", "w.caret"));
|
||||
expect(widgets).toEqual({ "w.memory": true, "w.caret": false });
|
||||
// Untitled or unknown widgets cannot be hidden.
|
||||
await act(() => executeAction("view.toggleStatusBarWidget", "w.tasks"));
|
||||
await act(() => executeAction("view.toggleStatusBarWidget", "w.gone"));
|
||||
expect(settingsSet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { StatusItemContribution } from "./contributions";
|
||||
import { useContributions, type StatusItemContribution } from "./contributions";
|
||||
|
||||
export type WidgetOverrides = Partial<Record<string, boolean>>;
|
||||
|
||||
@@ -14,25 +14,42 @@ export function isStatusItemVisible(
|
||||
return overrides?.[item.id] ?? item.defaultVisible !== false;
|
||||
}
|
||||
|
||||
export interface StatusWidgetRow {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Plugin widgets are named by their plugin, not translated. */
|
||||
verbatim: boolean;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/** Every titled widget of `items` (in bar order) with its visibility: the rows users can switch. */
|
||||
export function statusWidgetRows(
|
||||
items: StatusItemContribution[],
|
||||
overrides: WidgetOverrides | undefined,
|
||||
): StatusWidgetRow[] {
|
||||
return items
|
||||
.filter((i) => i.title)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
title: i.title!,
|
||||
verbatim: i.id.startsWith("plugin."),
|
||||
visible: isStatusItemVisible(i, overrides),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The status bar context menu: every titled widget with a checkmark, in bar order. */
|
||||
export function statusBarMenu(
|
||||
items: StatusItemContribution[],
|
||||
overrides: WidgetOverrides | undefined,
|
||||
set: (id: string, visible: boolean) => void = (id, v) => void setStatusWidgetVisible(id, v),
|
||||
): MenuItem[] {
|
||||
return items
|
||||
.filter((i) => i.title)
|
||||
.map((i) => {
|
||||
const visible = isStatusItemVisible(i, overrides);
|
||||
// Plugin widgets are named by their plugin, not translated.
|
||||
return {
|
||||
id: i.id,
|
||||
label: i.title!,
|
||||
verbatim: i.id.startsWith("plugin."),
|
||||
checked: visible,
|
||||
run: () => set(i.id, !visible),
|
||||
};
|
||||
});
|
||||
return statusWidgetRows(items, overrides).map((row) => ({
|
||||
id: row.id,
|
||||
label: row.title,
|
||||
verbatim: row.verbatim,
|
||||
checked: row.visible,
|
||||
run: () => set(row.id, !row.visible),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Persists the choice in the global settings (the whole map, since widget ids contain dots). */
|
||||
@@ -43,6 +60,14 @@ export async function setStatusWidgetVisible(id: string, visible: boolean): Prom
|
||||
useProject.setState({ settings });
|
||||
}
|
||||
|
||||
/** Flips one widget by id (View › Appearance › Status Bar Widgets); untitled or unknown widgets stay as they are. */
|
||||
export async function toggleStatusWidget(id: string): Promise<void> {
|
||||
const item = useContributions.getState().statusItems.get(id);
|
||||
if (!item?.title) return;
|
||||
const visible = isStatusItemVisible(item, useProject.getState().settings?.appearance?.statusBarWidgets);
|
||||
await setStatusWidgetVisible(id, !visible);
|
||||
}
|
||||
|
||||
export function useStatusWidgetOverrides(): WidgetOverrides | undefined {
|
||||
return useProject((s) => s.settings?.appearance?.statusBarWidgets);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
|
||||
const { emitLocalIdeEvent, isCancelled } = await import("@/platform/ipc");
|
||||
const { cancelTask, installTaskEvents, queueTask, setIndexing, upsertTask, useTasks } = await import("./tasks");
|
||||
const { TaskStatus } = await import("./StatusBar");
|
||||
const { BackgroundTasksHost, TaskStatus } = await import("./StatusBar");
|
||||
|
||||
const task = (id: string) => useTasks.getState().tasks.find((t) => t.id === id);
|
||||
|
||||
@@ -126,7 +126,12 @@ describe("cancelling", () => {
|
||||
done: false,
|
||||
uncancellable: "This step can't be cancelled",
|
||||
});
|
||||
render(<TaskStatus />);
|
||||
render(
|
||||
<>
|
||||
<TaskStatus />
|
||||
<BackgroundTasksHost />
|
||||
</>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Background tasks/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel Downloading JDK 25" }));
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
@@ -160,3 +165,19 @@ describe("cancelling", () => {
|
||||
uninstall();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows progress and uncancellable tasks through an action without the status bar", async () => {
|
||||
const { registerShellActions } = await import("./shellActions");
|
||||
const { executeAction } = await import("@/platform/actions");
|
||||
const dispose = registerShellActions();
|
||||
try {
|
||||
upsertTask({ id: "scan", title: "Scanning", message: "Reading files", fraction: 0.25, done: false });
|
||||
render(<BackgroundTasksHost />);
|
||||
await act(() => executeAction("view.backgroundTasks"));
|
||||
expect(screen.getByRole("dialog", { name: "Background tasks" })).toHaveTextContent("25%");
|
||||
expect(screen.getByText("Reading files")).toBeVisible();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача