workspace: Add configurable window title formatting (#54379)

## 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`:
<img width="774" height="324" alt="Settings Editor screenshot"
src="https://github.com/user-attachments/assets/b3378ec7-5649-4de3-9de6-164a42bce3fd"
/>

Customized Zed window titles in AltTab app switcher (remote, local,
empty):
<img width="774" height="324" alt="AltTab app switcher screenshot"
src="https://github.com/user-attachments/assets/a189945e-fc68-41b9-aab9-6dc04d5ef4ab"
/>

## 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 <kirill@zed.dev>
This commit is contained in:
Jake NelsonandKirill Bulatov authored and GitHub committed 2026-09-04 13:48:58 +00:00
1 parent f659fcc000
commit 1aab982b0a
11 files changed
+1008 -32

No files matched your search

Generated
+1
View File
@@ -22251,6 +22251,7 @@ dependencies = [
"project",
"proptest",
"proptest-derive",
"release_channel",
"remote",
"schemars 1.0.4",
"serde",
+17
View File
@@ -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.
+21
View File
@@ -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<SshConnectionOptions> for RemoteConnectionOptions {
+45
View File
@@ -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]
+102
View File
@@ -99,6 +99,15 @@ impl VsCodeSettings {
.map(|s| s.to_owned())
}
fn read_window_title_format(&self) -> Option<String> {
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<bool> {
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<T: Default + PartialEq>(value: T) -> Option<T> {
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())
);
}
}
+19
View File
@@ -146,6 +146,25 @@ pub struct WorkspaceSettingsContent {
///
/// Default: false
pub close_panel_on_toggle: Option<bool>,
/// 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>,
/// String substituted for `${separator}` in the window title format.
/// Include any surrounding whitespace in the value.
///
/// Default: ` — `
pub window_title_separator: Option<String>,
/// Controls whether Zed or the window manager or compositor draws window decorations on Linux.
///
/// Default: client
+40 -1
View File
@@ -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",
+1
View File
@@ -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
+714 -31
View File
@@ -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<String>,
file_path: Option<String>,
relative_path: Option<String>,
file_stem: Option<String>,
remote_name: Option<String>,
remote_host: Option<String>,
app_name: &'static str,
branch: Option<String>,
}
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<WindowTitleTemplatePart<'_>> {
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<Option<Size<Pixels>>> = 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<String>,
/// 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<EntityId, Subscription>,
active_call: Option<(GlobalAnyActiveCall, Vec<Subscription>)>,
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::<SettingsStore>(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::<SettingsStore>()
.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);
@@ -39,6 +39,8 @@ pub struct WorkspaceSettings {
pub resize_all_panels_in_dock: Vec<DockPosition>,
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(),
+44
View File
@@ -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.