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]>
Цей коміт міститься в:
lleviathe authored and GitHub committed 2026-11-09 07:20:54 +08:00
1 джерело 75565927d8
коміт 8dd29bad41
42 files changed
+1001 -144

No files matched your search

+5
Переглянути файл
@@ -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,
}
+16
Переглянути файл
@@ -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();
+29
Переглянути файл
@@ -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>)> {
+1
Переглянути файл
@@ -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"
+55 -17
Переглянути файл
@@ -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}");
}
}
}
+42 -8
Переглянути файл
@@ -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" };
+61 -5
Переглянути файл
@@ -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());
});
});
+40 -5
Переглянути файл
@@ -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 () => {
+55
Переглянути файл
@@ -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);
});
});
+7
Переглянути файл
@@ -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");
});
+36 -14
Переглянути файл
@@ -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"]),
];
}
+2 -1
Переглянути файл
@@ -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([
{
+7
Переглянути файл
@@ -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"),
};
}
+2
Переглянути файл
@@ -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",
+11 -5
Переглянути файл
@@ -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]);
+18
Переглянути файл
@@ -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);
});
+2 -1
Переглянути файл
@@ -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(),
+4
Переглянути файл
@@ -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",
+10 -1
Переглянути файл
@@ -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"
}
+9 -1
Переглянути файл
@@ -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": "状态栏微件"
}
+4
Переглянути файл
@@ -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": "最近的更改",
+2
Переглянути файл
@@ -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;
+4 -2
Переглянути файл
@@ -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;
+39 -2
Переглянути файл
@@ -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);
}
});
+21 -1
Переглянути файл
@@ -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();
});
});
+7 -1
Переглянути файл
@@ -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 }),
};
}
});
}
+54 -32
Переглянути файл
@@ -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>
);
}
+20
Переглянути файл
@@ -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;
+21
Переглянути файл
@@ -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");
});
});
+27 -11
Переглянути файл
@@ -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} />;
+12 -5
Переглянути файл
@@ -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} />
+2 -1
Переглянути файл
@@ -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({
+31 -1
Переглянути файл
@@ -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 });
});
});
+12
Переглянути файл
@@ -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);
}
+12
Переглянути файл
@@ -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);
+14
Переглянути файл
@@ -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]);
}
+17
Переглянути файл
@@ -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",
+23
Переглянути файл
@@ -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);
});
});
+39 -14
Переглянути файл
@@ -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);
}
+23 -2
Переглянути файл
@@ -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();
}
});