From 1aab982b0a6c5b3d2350fd2dc2e2cab5d73c2620 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Fri, 4 Sep 2026 13:48:58 +0000 Subject: [PATCH] workspace: Add configurable window title formatting (#54379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `window_title_format` and `window_title_separator` settings for customizing the window title - Expose `${projectName}`, `${fileName}`, `${filePath}`, `${relativePath}`, `${fileStem}`, `${remoteName}`, `${remoteHost}`, `${appName}`, and `${separator}` as the native Zed window-title variables - Keep the collab `↗/↙` indicator hard-appended - Translate VS Code `window.title` / `window.titleSeparator` aliases into Zed's canonical variables during settings import, including `${appName}` passthrough support and mapping VS Code's `${activeEditorMedium}` onto Zed's `${relativePath}` - Gate path-, remote-, and release-channel-derived lookups on whether the active template references the corresponding variable, so the default template avoids the extra work and still uses the existing `last_window_title` cache to skip redundant platform updates An alternative approach is at #49638. I went with `${variable}` names over `$ZED_*`, canonical Zed variable names with VS Code aliases handled at import time, kept `${separator}` as the only structural token, and left `${dirty}` out of scope. ## Implementation reasoning and notes ### Variables considered but not included - **collab/shared indicator variable**: kept hard-appended instead. Folding it into the template would need whitespace/separator logic the other variables don't need, and I'd rather not bake those semantics in without a concrete need for it. - **dirty variable**: The glyph choice and interaction with OS-level edited indicators (like macOS's titlebar dot via `set_window_edited`) felt like a separate design call I wasn't confident making here. A separator-style config for each of collab/shared/dirty would solve the above, but I didn't want to add too many additional settings initially. ### Performance `update_window_title` is on a hot path, so two short-circuits apply: - Path-, remote-, and release-channel-derived lookups (`absolute_path`, relative-path string, `file_stem`, `remote_connection_options`, `ReleaseChannel::try_global`) are gated on `template.contains(...)` via `WindowTitleNeeds::from_template`, so the default template avoids the extra work. - The existing `last_window_title` cache still skips the platform call when the rendered title is unchanged. ## Screenshots Settings Editor entries for `window_title_format` and `window_title_separator`: Settings Editor screenshot Customized Zed window titles in AltTab app switcher (remote, local, empty): AltTab app switcher screenshot ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #14534 Release Notes: - Added configurable window title formatting with `window_title_format` / `window_title_separator`, including `${appName}` / `${remoteName}` / `${remoteHost}` variables and VS Code `window.title` import support --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 1 + assets/settings/default.json | 17 + crates/remote/src/remote_client.rs | 21 + crates/settings/src/settings_store.rs | 45 ++ crates/settings/src/vscode_import.rs | 102 +++ crates/settings_content/src/workspace.rs | 19 + crates/settings_ui/src/page_data.rs | 41 +- crates/workspace/Cargo.toml | 1 + crates/workspace/src/workspace.rs | 745 ++++++++++++++++++++- crates/workspace/src/workspace_settings.rs | 4 + docs/src/reference/all-settings.md | 44 ++ 11 files changed, 1008 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a75d45cf3..3d95a3f31a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22251,6 +22251,7 @@ dependencies = [ "project", "proptest", "proptest-derive", + "release_channel", "remote", "schemars 1.0.4", "serde", diff --git a/assets/settings/default.json b/assets/settings/default.json index bf423f84b5..63c16cf6d9 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -241,6 +241,23 @@ // // Default: true "zoomed_padding": true, + // Window title template. + // Available variables are ${projectName}, ${fileName}, ${filePath}, + // ${relativePath}, ${fileStem}, ${remoteName}, ${remoteHost}, + // ${appName}, ${branch}, and ${separator}. + // ${separator} is omitted when adjacent variables are empty, + // but literal text is preserved. + // The collaboration indicator, when present, is appended after the + // rendered template. + // If the template renders to nothing, the default template is used instead. + // + // Default: "${projectName}${separator}${fileName}" + "window_title_format": "${projectName}${separator}${fileName}", + // String substituted for ${separator} in the window title format. + // Include any surrounding whitespace in the value. + // + // Default: " — " + "window_title_separator": " — ", // On Linux, what draws Zed's window decorations (titlebar). // This setting has no effect on other platforms: // 1. Zed draws its own window decorations. diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 64489598b4..91eb5c5a2f 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -1372,6 +1372,16 @@ impl RemoteConnectionOptions { RemoteConnectionOptions::Mock(_) => "mock", } } + + pub fn host(&self) -> String { + match self { + RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(), + RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(), + RemoteConnectionOptions::Docker(opts) => opts.name.clone(), + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(opts) => format!("mock-{}", opts.id), + } + } } #[cfg(test)] @@ -1563,6 +1573,17 @@ mod tests { "stream channel should be removed once the consumer has dropped the stream" ); } + + #[test] + fn test_ssh_host_ignores_nickname() { + let options = RemoteConnectionOptions::Ssh(SshConnectionOptions { + host: "1.2.3.4".into(), + nickname: Some("My Cool Project".to_string()), + ..Default::default() + }); + + assert_eq!(options.host(), "1.2.3.4"); + } } impl From for RemoteConnectionOptions { diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 6590d6be71..0c0b00652f 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -2673,6 +2673,51 @@ mod tests { .unindent(), cx, ); + + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ + "window.title": "${activeEditorShort}${separator}${rootName}${separator}${appName}", + "window.titleSeparator": " - " + }"# + .unindent(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "window_title_separator": " - ", + "window_title_format": "${fileName}${separator}${projectName}${separator}${appName}" + } + "# + .unindent(), + cx, + ); + + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ + "window.title": "${unsupportedVariable}" + }"# + .unindent(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } + } + "# + .unindent(), + cx, + ); } #[track_caller] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 582fcf01d3..fbf9df9c3a 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -99,6 +99,15 @@ impl VsCodeSettings { .map(|s| s.to_owned()) } + fn read_window_title_format(&self) -> Option { + self.read_string("window.title") + .map(|template| translate_vscode_window_title_format(&template)) + // If every placeholder is dropped during translation, keep Zed's + // default title behavior instead of importing a template that + // renders as nothing but whitespace. + .filter(|template| !template.trim().is_empty()) + } + fn read_bool(&self, setting: &str) -> Option { self.read_value(setting).and_then(|v| v.as_bool()) } @@ -1081,6 +1090,8 @@ impl VsCodeSettings { FullscreenMode::Simple } }), + window_title_format: self.read_window_title_format(), + window_title_separator: self.read_string("window.titleSeparator"), when_closing_with_no_tabs: self.read_bool("window.closeWhenEmpty").map(|b| { if b { CloseWindowWhenNoItems::CloseWindow @@ -1151,6 +1162,50 @@ impl VsCodeSettings { } } +fn translate_vscode_window_title_format(template: &str) -> String { + let mut translated = String::new(); + let mut start = 0; + + // Workspace owns the runtime template parser, so the VS Code importer keeps + // its own small placeholder scan here instead of depending on that crate. + while let Some(offset) = template[start..].find("${") { + let variable_start = start + offset; + translated.push_str(&template[start..variable_start]); + + let content_start = variable_start + 2; + let Some(content_end_offset) = template[content_start..].find('}') else { + translated.push_str(&template[variable_start..]); + return translated; + }; + + let content_end = content_start + content_end_offset; + let variable = &template[content_start..content_end]; + match variable { + "projectName" | "fileName" | "filePath" | "relativePath" | "fileStem" + | "remoteHost" | "appName" | "branch" | "separator" => { + translated.push_str(&template[variable_start..=content_end]); + } + // Keep VS Code alias support in the importer so native Zed settings + // only expose the documented placeholder names. + "rootName" => translated.push_str("${projectName}"), + "activeEditorShort" => translated.push_str("${fileName}"), + "activeEditorMedium" => translated.push_str("${relativePath}"), + "activeEditorLong" => translated.push_str("${filePath}"), + "activeRepositoryBranchName" => translated.push_str("${branch}"), + // VS Code's `${remoteName}` is a provider label such as `SSH`, while + // Zed's `${remoteName}` resolves to the connection's name or host, + // so the token is dropped rather than imported with mismatched + // semantics. + _ => {} + } + + start = content_end + 1; + } + + translated.push_str(&template[start..]); + translated +} + fn skip_default(value: T) -> Option { if value == T::default() { None @@ -1198,4 +1253,51 @@ mod tests { assert_eq!(settings.workspace.reveal_if_open, Some(true)); } + + #[test] + fn test_import_window_title_format() { + let imported_title = |content: &str| { + VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode) + .unwrap() + .settings_content() + .workspace + .window_title_format + }; + + assert_eq!(imported_title("{}"), None); + + // VS Code variables are translated to their Zed equivalents. + assert_eq!( + imported_title( + r#"{ "window.title": "${rootName} ${activeRepositoryBranchName} ${activeEditorMedium}" }"#, + ), + Some("${projectName} ${branch} ${relativePath}".to_string()) + ); + + // Zed-native variables pass through unchanged. + assert_eq!( + imported_title(r#"{ "window.title": "${projectName}${separator}${filePath}" }"#), + Some("${projectName}${separator}${filePath}".to_string()) + ); + + // VS Code's `${remoteName}` is a provider label such as `SSH`, which + // doesn't match Zed's connection-name semantics, so it is dropped. + assert_eq!( + imported_title(r#"{ "window.title": "${remoteName}: ${rootName}" }"#), + Some(": ${projectName}".to_string()) + ); + + // Templates that translate to nothing but whitespace fall back to + // Zed's default title instead of overriding it. + assert_eq!( + imported_title(r#"{ "window.title": " ${activeFolderShort} " }"#), + None + ); + + // Literal text around unsupported variables is still imported. + assert_eq!( + imported_title(r#"{ "window.title": "${activeFolderShort} — literal" }"#), + Some(" — literal".to_string()) + ); + } } diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index bcbcadcc8a..4e27a8d9a8 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -146,6 +146,25 @@ pub struct WorkspaceSettingsContent { /// /// Default: false pub close_panel_on_toggle: Option, + /// Window title template. + /// + /// Available variables are `${projectName}`, `${fileName}`, + /// `${filePath}`, `${relativePath}`, `${fileStem}`, `${remoteName}`, + /// `${remoteHost}`, `${appName}`, `${branch}`, + /// and `${separator}`. + /// `${separator}` is omitted when adjacent variables are empty, + /// but literal text is preserved. + /// The collaboration indicator, when present, is appended after the + /// rendered template. + /// If the template renders to nothing, the default template is used instead. + /// + /// Default: `${projectName}${separator}${fileName}` + pub window_title_format: Option, + /// String substituted for `${separator}` in the window title format. + /// Include any surrounding whitespace in the value. + /// + /// Default: ` — ` + pub window_title_separator: Option, /// Controls whether Zed or the window manager or compositor draws window decorations on Linux. /// /// Default: client diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index d647652289..dea5732f0f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -5067,9 +5067,48 @@ fn window_and_layout_page() -> SettingsPage { ] } - fn window_section() -> [SettingsPageItem; 4] { + fn window_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Window"), + SettingsPageItem::SettingItem(SettingItem { + title: "Title Format", + description: "Window title template. Available variables are `${projectName}`, `${fileName}`, `${filePath}`, `${relativePath}`, `${fileStem}`, `${remoteName}`, `${remoteHost}`, `${appName}`, `${branch}`, and `${separator}`. `${separator}` is omitted when adjacent variables are empty, but literal text is preserved. The collaboration indicator, when present, is appended after the rendered template. If the template renders to nothing, the default template is used instead.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("window_title_format"), + pick: |settings_content| { + settings_content.workspace.window_title_format.as_ref() + }, + write: |settings_content, value, _| { + settings_content.workspace.window_title_format = + value.filter(|format| !format.is_empty()); + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some("${projectName}${separator}${fileName}"), + ..Default::default() + })), + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Title Separator", + description: "String substituted for `${separator}` in the window title format. Include any surrounding whitespace in the value.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("window_title_separator"), + pick: |settings_content| { + settings_content.workspace.window_title_separator.as_ref() + }, + write: |settings_content, value, _| { + settings_content.workspace.window_title_separator = value; + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some(" — "), + ..Default::default() + })), + files: USER, + }), // todo(settings_ui): Should we filter by platform.as_ref()? SettingsPageItem::SettingItem(SettingItem { title: "Use System Window Tabs", diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index abb49eda1f..55a47c73e7 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -52,6 +52,7 @@ node_runtime.workspace = true parking_lot.workspace = true postage.workspace = true project.workspace = true +release_channel.workspace = true remote.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d939f9bfa1..70cf387517 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -100,10 +100,12 @@ use project::{ DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, WorktreeSettings, debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus}, + git_store::{GitStoreEvent, RepositoryEvent}, project_settings::ProjectSettings, toolchain_store::ToolchainStoreEvent, trusted_worktrees::{RemoteHostLocation, TrustedWorktrees, TrustedWorktreesEvent}, }; +use release_channel::ReleaseChannel; use remote::{ RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, remote_client::ConnectionIdentifier, @@ -174,6 +176,140 @@ use crate::{ pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); pub const MAX_RECENT_SELECTIONS: usize = 20; +/// Which optional window-title variables are actually referenced by the active +/// template. Used to skip expensive lookups when the template doesn't need them. +struct WindowTitleNeeds { + file_path: bool, + relative_path: bool, + file_stem: bool, + remote: bool, + app_name: bool, + branch: bool, +} + +impl WindowTitleNeeds { + fn from_template(template: &str) -> Self { + Self { + file_path: template.contains("${filePath}"), + relative_path: template.contains("${relativePath}"), + file_stem: template.contains("${fileStem}"), + remote: template.contains("${remoteName}") || template.contains("${remoteHost}"), + app_name: template.contains("${appName}"), + branch: template.contains("${branch}"), + } + } +} + +#[derive(Default)] +struct WindowTitleContext { + project_name: String, + file_name: Option, + file_path: Option, + relative_path: Option, + file_stem: Option, + remote_name: Option, + remote_host: Option, + app_name: &'static str, + branch: Option, +} + +enum WindowTitleTemplatePart<'a> { + Literal(&'a str), + Variable(&'a str), + Separator, +} + +impl WindowTitleContext { + fn value_for(&self, variable: &str) -> Option<&str> { + match variable { + "projectName" => Some(self.project_name.as_str()), + "fileName" => self.file_name.as_deref(), + "filePath" => self.file_path.as_deref(), + "relativePath" => self.relative_path.as_deref(), + "fileStem" => self.file_stem.as_deref(), + "remoteName" => self.remote_name.as_deref(), + "remoteHost" => self.remote_host.as_deref(), + "appName" => Some(self.app_name), + "branch" => self.branch.as_deref(), + // Unknown placeholders collapse like missing values so imported and + // native templates follow the same rendering rules. + _ => None, + } + } +} + +fn parse_window_title_format(template: &str) -> Vec> { + let mut parts = Vec::new(); + let mut start = 0; + + // Keep this placeholder scan in sync with the importer in + // settings/src/vscode_import.rs. + while let Some(offset) = template[start..].find("${") { + let variable_start = start + offset; + if variable_start > start { + parts.push(WindowTitleTemplatePart::Literal( + &template[start..variable_start], + )); + } + + let content_start = variable_start + 2; + let Some(content_end_offset) = template[content_start..].find('}') else { + parts.push(WindowTitleTemplatePart::Literal( + &template[variable_start..], + )); + return parts; + }; + + let content_end = content_start + content_end_offset; + let variable = &template[content_start..content_end]; + if variable == "separator" { + parts.push(WindowTitleTemplatePart::Separator); + } else { + parts.push(WindowTitleTemplatePart::Variable(variable)); + } + + start = content_end + 1; + } + + if start < template.len() { + parts.push(WindowTitleTemplatePart::Literal(&template[start..])); + } + + parts +} + +fn render_window_title_format( + template: &str, + separator: &str, + context: &WindowTitleContext, +) -> String { + let parts = parse_window_title_format(template); + let mut segments = Vec::new(); + let mut current_segment = String::new(); + + for part in parts { + match part { + WindowTitleTemplatePart::Literal(text) => current_segment.push_str(text), + WindowTitleTemplatePart::Variable(variable) => { + if let Some(value) = context.value_for(variable) { + current_segment.push_str(value); + } + } + WindowTitleTemplatePart::Separator => { + if !current_segment.is_empty() { + segments.push(std::mem::take(&mut current_segment)); + } + } + } + } + + if !current_segment.is_empty() { + segments.push(current_segment); + } + + segments.join(separator) +} + static ZED_WINDOW_SIZE: LazyLock>> = LazyLock::new(|| { env::var("ZED_WINDOW_SIZE") .ok() @@ -1472,6 +1608,10 @@ pub struct Workspace { auto_watch: AutoWatch, window_edited: bool, last_window_title: Option, + /// The `(window_title_format, window_title_separator)` pair last applied to + /// the window, used to skip title recomputation when unrelated settings + /// change. + last_window_title_settings: Option<(String, String)>, dirty_items: HashMap, active_call: Option<(GlobalAnyActiveCall, Vec)>, leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, @@ -1868,6 +2008,39 @@ impl Workspace { let subscriptions = vec![ cx.observe_window_activation(window, Self::on_window_activation_changed), + cx.observe_global_in::(window, |this, window, cx| { + // Settings can only affect the title through these two values, + // so skip the recomputation when they are unchanged. + let settings = WorkspaceSettings::get_global(cx); + let title_settings = ( + settings.window_title_format.as_str(), + settings.window_title_separator.as_str(), + ); + let last_title_settings = this + .last_window_title_settings + .as_ref() + .map(|(format, separator)| (format.as_str(), separator.as_str())); + if last_title_settings != Some(title_settings) { + this.update_window_title(window, cx); + } + }), + cx.subscribe_in( + &project.read(cx).git_store().clone(), + window, + |this, _, event, window, cx| match event { + GitStoreEvent::ActiveRepositoryChanged(_) + | GitStoreEvent::RepositoryUpdated( + _, + RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged, + true, + ) => { + if this.window_title_needs_branch(cx) { + this.update_window_title(window, cx); + } + } + _ => {} + }, + ), cx.observe_window_bounds(window, move |this, window, cx| { if !window.is_window_active() { return; @@ -1945,6 +2118,7 @@ impl Workspace { dispatching_keystrokes: Default::default(), window_edited: false, last_window_title: None, + last_window_title_settings: None, dirty_items: Default::default(), active_call, database_id: workspace_id, @@ -6494,39 +6668,45 @@ impl Workspace { self.apply_window_title(window, cx); } + /// Whether the active window-title template references `${branch}`, and so + /// can be affected by Git repository events. + fn window_title_needs_branch(&self, cx: &App) -> bool { + WindowTitleNeeds::from_template(&WorkspaceSettings::get_global(cx).window_title_format) + .branch + } + fn apply_window_title(&mut self, window: &mut Window, cx: &mut App) { let project = self.project().read(cx); - let mut title = String::new(); - - for (i, worktree) in project.visible_worktrees(cx).enumerate() { - let name = worktree.read(cx).root_name_str(); - - if i > 0 { - title.push_str(", "); - } - title.push_str(name); - } - - if title.is_empty() { - title = "empty project".to_string(); - } - let active_project_path = self.active_item(cx).and_then(|item| item.project_path(cx)); - - if let Some(path) = active_project_path.as_ref() { - let filename = path.path.file_name().or_else(|| { - Some( - project - .worktree_for_id(path.worktree_id, cx)? - .read(cx) - .root_name_str(), - ) - }); - - if let Some(filename) = filename { - title.push_str(" — "); - title.push_str(filename.as_ref()); - } + let settings = WorkspaceSettings::get_global(cx); + let template = settings.window_title_format.as_str(); + let separator = settings.window_title_separator.as_str(); + let settings_changed = self.last_window_title_settings.as_ref().is_none_or( + |(last_template, last_separator)| { + (last_template.as_str(), last_separator.as_str()) != (template, separator) + }, + ); + if settings_changed { + self.last_window_title_settings = Some((template.to_string(), separator.to_string())); + } + let needs = WindowTitleNeeds::from_template(template); + let context = + Self::window_title_context(&project, active_project_path.as_ref(), &needs, cx); + let mut title = render_window_title_format(template, separator, &context); + // Keep the normal title when a custom template resolves entirely to + // empty or unknown placeholders. + if title.trim().is_empty() + && let Some(default_template) = cx + .global::() + .raw_default_settings() + .workspace + .window_title_format + .as_deref() + { + let needs = WindowTitleNeeds::from_template(default_template); + let context = + Self::window_title_context(&project, active_project_path.as_ref(), &needs, cx); + title = render_window_title_format(default_template, separator, &context); } if project.is_via_collab() { @@ -6558,6 +6738,89 @@ impl Workspace { !self.project.read(cx).is_disconnected(cx) && !self.dirty_items.is_empty() } + fn window_title_context( + project: &Project, + project_path: Option<&ProjectPath>, + needs: &WindowTitleNeeds, + cx: &App, + ) -> WindowTitleContext { + let project_name = project_window_title(project, cx); + let path_style = project.path_style(cx); + + let (file_name, file_path, relative_path, file_stem) = project_path + .map(|project_path| { + let file_name = project_path + .path + .file_name() + .map(|file_name| file_name.to_string()) + .or_else(|| { + Some( + project + .worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .root_name_str() + .to_string(), + ) + }); + let file_path = if needs.file_path { + project + .absolute_path(project_path, cx) + .map(|path| path.to_string_lossy().into_owned()) + } else { + None + }; + let relative_path = if needs.relative_path { + (!project_path.path.as_unix_str().is_empty()) + .then(|| project_path.path.display(path_style).to_string()) + } else { + None + }; + let file_stem = if needs.file_stem { + project_path.path.file_stem().map(|s| s.to_string()) + } else { + None + }; + (file_name, file_path, relative_path, file_stem) + }) + .unwrap_or((None, None, None, None)); + + let remote_options = if needs.remote { + project.remote_connection_options(cx) + } else { + None + }; + let remote_name = remote_options + .as_ref() + .map(RemoteConnectionOptions::display_name); + let remote_host = remote_options.as_ref().map(RemoteConnectionOptions::host); + + let branch = if needs.branch { + project + .active_repository(cx) + .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name().to_owned())) + } else { + None + }; + + WindowTitleContext { + project_name, + file_name, + file_path, + relative_path, + file_stem, + remote_name, + remote_host, + app_name: if needs.app_name { + ReleaseChannel::try_global(cx) + .unwrap_or(ReleaseChannel::Stable) + .display_name() + } else { + "" + }, + branch, + } + } + fn update_window_edited(&mut self, window: &mut Window, cx: &mut App) { if !self.owns_window_chrome() { return; @@ -6565,7 +6828,7 @@ impl Workspace { let is_edited = self.is_window_edited(cx); if is_edited != self.window_edited { self.window_edited = is_edited; - window.set_window_edited(self.window_edited) + window.set_window_edited(self.window_edited); } } @@ -8835,6 +9098,25 @@ impl Workspace { } } +fn project_window_title(project: &Project, cx: &App) -> String { + let mut title = String::new(); + + for (index, worktree) in project.visible_worktrees(cx).enumerate() { + let name = worktree.read(cx).root_name_str(); + if index > 0 { + title.push_str(", "); + } + title.push_str(name); + } + + if title.is_empty() { + // Keep the default untitled-window text instead of showing a blank title. + "empty project".to_string() + } else { + title + } +} + pub trait AnyActiveCall { fn entity(&self) -> AnyEntity; fn is_in_room(&self, _: &App) -> bool; @@ -12114,6 +12396,143 @@ mod tests { use util::path; use util::rel_path::rel_path; + #[test] + fn test_render_window_title_format_omits_empty_segments() { + let context = WindowTitleContext { + project_name: "project".to_string(), + file_name: None, + file_path: Some("/tmp/project/src/main.rs".to_string()), + relative_path: Some("src/main.rs".to_string()), + file_stem: Some("main".to_string()), + remote_name: Some("nickname".to_string()), + remote_host: Some("example.com".to_string()), + app_name: "Zed", + branch: Some("main".to_string()), + }; + + assert_eq!( + render_window_title_format( + "${projectName}${separator}${fileName}${separator}${remoteHost}", + " — ", + &context, + ), + "project — example.com" + ); + assert_eq!( + render_window_title_format( + "${fileName}${separator}${projectName}${separator}${remoteName}", + " — ", + &context, + ), + "project — nickname" + ); + assert_eq!( + render_window_title_format( + "${projectName}${separator}${relativePath}${separator}${remoteName}${separator}${remoteHost}", + " — ", + &context, + ), + "project — src/main.rs — nickname — example.com" + ); + assert_eq!( + render_window_title_format( + "${projectName}${separator}${remoteHost}${separator}${fileName}", + " | ", + &context, + ), + "project | example.com" + ); + assert_eq!( + render_window_title_format("${projectName}${separator}", " — ", &context), + "project" + ); + assert_eq!( + render_window_title_format("${projectName}${separator}${fileStem}", " — ", &context), + "project — main" + ); + } + + #[test] + fn test_render_window_title_format_omits_unknown_variables() { + let context = WindowTitleContext { + project_name: "project".to_string(), + file_name: Some("main.rs".to_string()), + file_path: Some("/tmp/project/src/main.rs".to_string()), + relative_path: Some("src/main.rs".to_string()), + file_stem: Some("main".to_string()), + remote_name: None, + remote_host: None, + app_name: "Zed", + branch: None, + }; + + assert_eq!( + render_window_title_format( + "${projectName}${separator}${typoName}${separator}${fileName}", + " — ", + &context, + ), + "project — main.rs" + ); + assert_eq!( + render_window_title_format("${typoName}", " — ", &context), + "" + ); + } + + #[test] + fn test_render_window_title_format_renders_new_variables() { + let context = WindowTitleContext { + project_name: "project".to_string(), + app_name: "Zed", + branch: Some("feature/foo".to_string()), + ..Default::default() + }; + + assert_eq!( + render_window_title_format("${projectName}${separator}${appName}", " — ", &context), + "project — Zed" + ); + assert_eq!( + render_window_title_format("${projectName}${separator}${branch}", " — ", &context), + "project — feature/foo" + ); + } + + #[test] + fn test_window_title_needs_from_template() { + let default = WindowTitleNeeds::from_template("${projectName}${separator}${fileName}"); + assert!(!default.file_path); + assert!(!default.relative_path); + assert!(!default.file_stem); + assert!(!default.remote); + assert!(!default.app_name); + assert!(!default.branch); + + let all = WindowTitleNeeds::from_template( + "${filePath} ${relativePath} ${fileStem} ${remoteName} ${remoteHost} ${appName} ${branch}", + ); + assert!(all.file_path); + assert!(all.relative_path); + assert!(all.file_stem); + assert!(all.remote); + assert!(all.app_name); + assert!(all.branch); + + // `remote` is shared between the two remote-* placeholders; either one + // flips the flag on its own. + assert!(WindowTitleNeeds::from_template("${remoteName}").remote); + assert!(WindowTitleNeeds::from_template("${remoteHost}").remote); + assert!(!WindowTitleNeeds::from_template("${projectName}").remote); + + // Substrings and unrelated text must not trigger the expensive path. + let noise = WindowTitleNeeds::from_template("filePath relativePath remote ${projectName}"); + assert!(!noise.file_path); + assert!(!noise.relative_path); + assert!(!noise.remote); + assert!(!noise.branch); + } + #[gpui::test] async fn test_tab_disambiguation(cx: &mut TestAppContext) { init_test(cx); @@ -12337,6 +12756,270 @@ mod tests { assert_eq!(cx.document_path(), None); } + #[gpui::test] + async fn test_window_title_format_setting(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["root1".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "src/one.txt", cx)]) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + assert_eq!(cx.window_title().as_deref(), Some("root1 — one.txt")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${relativePath}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + // `${relativePath}` renders with the project's path style, which uses + // `\` separators on Windows. + let expected_relative_path = cx.update(|_, cx| { + let path_style = project.read(cx).path_style(cx); + rel_path("src/one.txt").display(path_style).to_string() + }); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("root1 — {expected_relative_path}").as_str()) + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.close_panel_on_toggle = Some(true); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("root1 — {expected_relative_path}").as_str()) + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_separator = Some(" | ".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("root1 | {expected_relative_path}").as_str()) + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${remoteHost}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some("${typoName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 | one.txt")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some(" ".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 | one.txt")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some(" ${remoteHost}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 | one.txt")); + } + + #[gpui::test] + async fn test_window_title_format_path_variables(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root1"), json!({ "src": { "one.txt": "" } })) + .await; + fs.insert_tree(path!("/root2"), json!({})).await; + let project = Project::test( + fs.clone(), + [path!("/root1").as_ref(), path!("/root2").as_ref()], + cx, + ) + .await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some( + "${appName}${separator}${projectName}${separator}${fileStem}${separator}${filePath}" + .to_string(), + ); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("Zed — root1, root2")); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new_in_worktree( + 1, + "src/one.txt", + worktree_id, + cx, + )]) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + cx.executor().run_until_parked(); + let expected_file_path = path!("/root1/src/one.txt"); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("Zed — root1, root2 — one — {expected_file_path}").as_str()) + ); + } + + #[gpui::test] + async fn test_window_title_format_empty_project(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (_workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some("${fileName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("empty project")); + } + + #[gpui::test] + async fn test_window_title_format_branch(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root1"), json!({ ".git": {}, "a.txt": "" })) + .await; + fs.set_branch_name(Path::new(path!("/root1/.git")), Some("main")); + let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some( + "${projectName}${separator}${branch}${separator}${fileName}".to_string(), + ); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — main")); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "a.txt", cx)]) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — main — a.txt")); + + fs.set_branch_name(Path::new(path!("/root1/.git")), Some("feature")); + cx.executor().run_until_parked(); + assert_eq!( + cx.window_title().as_deref(), + Some("root1 — feature — a.txt") + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${fileName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — a.txt")); + + fs.set_branch_name(Path::new(path!("/root1/.git")), Some("other")); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — a.txt")); + } + + #[gpui::test] + async fn test_window_title_collab_indicator_remains_appended(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["root1".as_ref()], cx).await; + project.update(cx, |project, _| project.mark_as_collab_for_testing()); + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "src/one.txt", cx)]) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${fileName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — one.txt ↙")); + } + #[gpui::test] async fn test_close_window(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index edc9e11138..eff3a0a93c 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -39,6 +39,8 @@ pub struct WorkspaceSettings { pub resize_all_panels_in_dock: Vec, pub close_on_file_delete: bool, pub close_panel_on_toggle: bool, + pub window_title_format: String, + pub window_title_separator: String, pub use_system_window_tabs: bool, pub fullscreen_mode: settings::FullscreenMode, pub zoomed_padding: bool, @@ -142,6 +144,8 @@ impl Settings for WorkspaceSettings { .collect(), close_on_file_delete: workspace.close_on_file_delete.unwrap(), close_panel_on_toggle: workspace.close_panel_on_toggle.unwrap(), + window_title_format: workspace.window_title_format.clone().unwrap(), + window_title_separator: workspace.window_title_separator.clone().unwrap(), use_system_window_tabs: workspace.use_system_window_tabs.unwrap(), fullscreen_mode: workspace.fullscreen_mode.unwrap(), zoomed_padding: workspace.zoomed_padding.unwrap(), diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index d688e3e963..124cf11a39 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -5273,6 +5273,50 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - `show_menus`: Whether to show the menus in the titlebar - `button_layout`: The layout of window control buttons in the title bar (Linux only). Can be set to `"platform_default"` to follow the system setting, `"standard"` to use Zed's built-in layout, or a custom format like `"close:minimize,maximize"` +## Window Title Format + +- Description: Template for the window title. Use `${separator}` to insert a + separator that is omitted when adjacent variables are empty. The collaboration + indicator, when present, is appended after the rendered template. If the + template renders to nothing (for example `${branch}` outside a Git + repository), the default template is used instead. +- Setting: `window_title_format` +- Default: `"${projectName}${separator}${fileName}"` + +Available variables: + +| Variable | Description | +| -------------- | ----------------------------------------------------- | +| `projectName` | Name of the current project | +| `fileName` | Name of the active file (e.g. `main.rs`) | +| `filePath` | Absolute path of the active file | +| `relativePath` | Path of the active file relative to its worktree root | +| `fileStem` | File name without extension (e.g. `main`) | +| `remoteName` | Display name of the remote connection, if any | +| `remoteHost` | Host of the remote connection, if any | +| `appName` | Zed release channel name (e.g. `Zed`, `Zed Nightly`) | +| `branch` | Git branch checked out in the active repository | +| `separator` | Separator string, omitted when neighbors are empty | + +```json [settings] +{ + "window_title_format": "${projectName}${separator}${fileName}" +} +``` + +## Window Title Separator + +- Description: The string substituted for `${separator}` in the window title + format. Include any surrounding whitespace in the value. +- Setting: `window_title_separator` +- Default: `" — "` + +```json [settings] +{ + "window_title_separator": " — " +} +``` + ## Window Decorations - Description: Controls whether Zed or the window manager or compositor draws window decorations.