History manager (#26369)

张小白 and Mikayla Maki created

While working on implementing `add_recent_documents` for Windows, I
found that the process is significantly more complex compared to macOS.
On macOS, simply registering the `add_recent_documents` function is
enough, as the system handles everything automatically.

On Windows, however, there are two cases to consider:  
- **Files opened by the app**: These appear in the "Recent" section (as
shown in the screenshot, "test.txt") and are managed automatically by
Windows (by setting windows registry), similar to macOS.

![屏幕截图 2025-03-10
230738](https://github.com/user-attachments/assets/8fc8063b-4369-43cc-aaaf-7370a7d27060)


- **Folders opened by the app**: This is more complicated because
Windows does not handle it automatically, requiring the application to
track opened folders manually.

To address this, this PR introduces a `History Manager` along with
`HistoryManagerEvent::Update` and `HistoryManagerEvent::Delete` events
to simplify the process of managing recently opened folders.



https://github.com/user-attachments/assets/a2581c15-7653-4faf-96b0-7c48ab1dcc8d



Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>

Change summary

Cargo.lock                                           |   1 
Cargo.toml                                           |  12 
crates/gpui/src/app.rs                               |  13 
crates/gpui/src/platform.rs                          |   7 
crates/gpui/src/platform/linux/platform.rs           |   4 
crates/gpui/src/platform/windows.rs                  |   2 
crates/gpui/src/platform/windows/destination_list.rs | 211 ++++++++++++++
crates/gpui/src/platform/windows/platform.rs         |  80 ++--
crates/recent_projects/src/recent_projects.rs        |  12 
crates/workspace/Cargo.toml                          |   5 
crates/workspace/src/history_manager.rs              | 129 ++++++++
crates/workspace/src/persistence.rs                  |   2 
crates/workspace/src/workspace.rs                    |  57 ++
crates/zed/src/zed.rs                                |  13 
14 files changed, 482 insertions(+), 66 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -17602,6 +17602,7 @@ dependencies = [
  "ui",
  "util",
  "uuid",
+ "windows 0.61.1",
  "workspace-hack",
  "zed_actions",
 ]

Cargo.toml 🔗

@@ -399,8 +399,12 @@ async-tungstenite = "0.29.1"
 async-watch = "0.3.1"
 async_zip = { version = "0.0.17", features = ["deflate", "deflate64"] }
 aws-config = { version = "1.6.1", features = ["behavior-version-latest"] }
-aws-credential-types = { version = "1.2.2", features = ["hardcoded-credentials"] }
-aws-sdk-bedrockruntime = { version = "1.80.0", features = ["behavior-version-latest"] }
+aws-credential-types = { version = "1.2.2", features = [
+    "hardcoded-credentials",
+] }
+aws-sdk-bedrockruntime = { version = "1.80.0", features = [
+    "behavior-version-latest",
+] }
 aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
 aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
 base64 = "0.22"
@@ -615,12 +619,10 @@ features = [
 [workspace.dependencies.windows]
 version = "0.61"
 features = [
-    "Foundation_Collections",
     "Foundation_Numerics",
     "Storage_Search",
     "Storage_Streams",
     "System_Threading",
-    "UI_StartScreen",
     "UI_ViewManagement",
     "Wdk_System_SystemServices",
     "Win32_Globalization",
@@ -647,6 +649,7 @@ features = [
     "Win32_System_SystemInformation",
     "Win32_System_SystemServices",
     "Win32_System_Threading",
+    "Win32_System_Variant",
     "Win32_System_WinRT",
     "Win32_UI_Controls",
     "Win32_UI_HiDpi",
@@ -654,6 +657,7 @@ features = [
     "Win32_UI_Input_KeyboardAndMouse",
     "Win32_UI_Shell",
     "Win32_UI_Shell_Common",
+    "Win32_UI_Shell_PropertiesSystem",
     "Win32_UI_WindowsAndMessaging",
 ]
 

crates/gpui/src/app.rs 🔗

@@ -25,6 +25,7 @@ use collections::{FxHashMap, FxHashSet, HashMap, VecDeque};
 pub use context::*;
 pub use entity_map::*;
 use http_client::HttpClient;
+use smallvec::SmallVec;
 #[cfg(any(test, feature = "test-support"))]
 pub use test_context::*;
 use util::ResultExt;
@@ -1430,7 +1431,7 @@ impl App {
 
     /// Sets the right click menu for the app icon in the dock
     pub fn set_dock_menu(&self, menus: Vec<MenuItem>) {
-        self.platform.set_dock_menu(menus, &self.keymap.borrow());
+        self.platform.set_dock_menu(menus, &self.keymap.borrow())
     }
 
     /// Performs the action associated with the given dock menu item, only used on Windows for now.
@@ -1446,6 +1447,16 @@ impl App {
         self.platform.add_recent_document(path);
     }
 
+    /// Updates the jump list with the updated list of recent paths for the application, only used on Windows for now.
+    /// Note that this also sets the dock menu on Windows.
+    pub fn update_jump_list(
+        &self,
+        menus: Vec<MenuItem>,
+        entries: Vec<SmallVec<[PathBuf; 2]>>,
+    ) -> Vec<SmallVec<[PathBuf; 2]>> {
+        self.platform.update_jump_list(menus, entries)
+    }
+
     /// Dispatch an action to the currently active window or global action handler
     /// See [`crate::Action`] for more information on how actions work
     pub fn dispatch_action(&mut self, action: &dyn Action) {

crates/gpui/src/platform.rs 🔗

@@ -203,6 +203,13 @@ pub(crate) trait Platform: 'static {
     fn set_dock_menu(&self, menu: Vec<MenuItem>, keymap: &Keymap);
     fn perform_dock_menu_action(&self, _action: usize) {}
     fn add_recent_document(&self, _path: &Path) {}
+    fn update_jump_list(
+        &self,
+        _menus: Vec<MenuItem>,
+        _entries: Vec<SmallVec<[PathBuf; 2]>>,
+    ) -> Vec<SmallVec<[PathBuf; 2]>> {
+        Vec::new()
+    }
     fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>);
     fn on_will_open_app_menu(&self, callback: Box<dyn FnMut()>);
     fn on_validate_app_menu_command(&self, callback: Box<dyn FnMut(&dyn Action) -> bool>);

crates/gpui/src/platform/linux/platform.rs 🔗

@@ -440,7 +440,9 @@ impl<P: LinuxClient + 'static> Platform for P {
         self.with_common(|common| Some(common.menus.clone()))
     }
 
-    fn set_dock_menu(&self, _menu: Vec<MenuItem>, _keymap: &Keymap) {}
+    fn set_dock_menu(&self, _menu: Vec<MenuItem>, _keymap: &Keymap) {
+        // todo(linux)
+    }
 
     fn path_for_auxiliary_executable(&self, _name: &str) -> Result<PathBuf> {
         Err(anyhow::Error::msg(

crates/gpui/src/platform/windows.rs 🔗

@@ -1,4 +1,5 @@
 mod clipboard;
+mod destination_list;
 mod direct_write;
 mod dispatcher;
 mod display;
@@ -10,6 +11,7 @@ mod window;
 mod wrapper;
 
 pub(crate) use clipboard::*;
+pub(crate) use destination_list::*;
 pub(crate) use direct_write::*;
 pub(crate) use dispatcher::*;
 pub(crate) use display::*;

crates/gpui/src/platform/windows/destination_list.rs 🔗

@@ -0,0 +1,211 @@
+use std::path::PathBuf;
+
+use itertools::Itertools;
+use smallvec::SmallVec;
+use windows::{
+    Win32::{
+        Foundation::PROPERTYKEY,
+        Globalization::u_strlen,
+        System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, StructuredStorage::PROPVARIANT},
+        UI::{
+            Controls::INFOTIPSIZE,
+            Shell::{
+                Common::{IObjectArray, IObjectCollection},
+                DestinationList, EnumerableObjectCollection, ICustomDestinationList, IShellLinkW,
+                PropertiesSystem::IPropertyStore,
+                ShellLink,
+            },
+        },
+    },
+    core::{GUID, HSTRING, Interface},
+};
+
+use crate::{Action, MenuItem};
+
+pub(crate) struct JumpList {
+    pub(crate) dock_menus: Vec<DockMenuItem>,
+    pub(crate) recent_workspaces: Vec<SmallVec<[PathBuf; 2]>>,
+}
+
+impl JumpList {
+    pub(crate) fn new() -> Self {
+        Self {
+            dock_menus: Vec::new(),
+            recent_workspaces: Vec::new(),
+        }
+    }
+}
+
+pub(crate) struct DockMenuItem {
+    pub(crate) name: String,
+    pub(crate) description: String,
+    pub(crate) action: Box<dyn Action>,
+}
+
+impl DockMenuItem {
+    pub(crate) fn new(item: MenuItem) -> anyhow::Result<Self> {
+        match item {
+            MenuItem::Action { name, action, .. } => Ok(Self {
+                name: name.clone().into(),
+                description: if name == "New Window" {
+                    "Opens a new window".to_string()
+                } else {
+                    name.into()
+                },
+                action,
+            }),
+            _ => Err(anyhow::anyhow!(
+                "Only `MenuItem::Action` is supported for dock menu on Windows."
+            )),
+        }
+    }
+}
+
+// This code is based on the example from Microsoft:
+// https://github.com/microsoft/Windows-classic-samples/blob/main/Samples/Win7Samples/winui/shell/appshellintegration/RecipePropertyHandler/RecipePropertyHandler.cpp
+pub(crate) fn update_jump_list(
+    jump_list: &JumpList,
+) -> anyhow::Result<Vec<SmallVec<[PathBuf; 2]>>> {
+    let (list, removed) = create_destination_list()?;
+    add_recent_folders(&list, &jump_list.recent_workspaces, removed.as_ref())?;
+    add_dock_menu(&list, &jump_list.dock_menus)?;
+    unsafe { list.CommitList() }?;
+    Ok(removed)
+}
+
+// Copied from:
+// https://github.com/microsoft/windows-rs/blob/0fc3c2e5a13d4316d242bdeb0a52af611eba8bd4/crates/libs/windows/src/Windows/Win32/Storage/EnhancedStorage/mod.rs#L1881
+const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY {
+    fmtid: GUID::from_u128(0xf29f85e0_4ff9_1068_ab91_08002b27b3d9),
+    pid: 2,
+};
+
+fn create_destination_list() -> anyhow::Result<(ICustomDestinationList, Vec<SmallVec<[PathBuf; 2]>>)>
+{
+    let list: ICustomDestinationList =
+        unsafe { CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER) }?;
+
+    let mut slots = 0;
+    let user_removed: IObjectArray = unsafe { list.BeginList(&mut slots) }?;
+
+    let count = unsafe { user_removed.GetCount() }?;
+    if count == 0 {
+        return Ok((list, Vec::new()));
+    }
+
+    let mut removed = Vec::with_capacity(count as usize);
+    for i in 0..count {
+        let shell_link: IShellLinkW = unsafe { user_removed.GetAt(i)? };
+        let description = {
+            // INFOTIPSIZE is the maximum size of the buffer
+            // see https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-ishelllinkw-getdescription
+            let mut buffer = [0u16; INFOTIPSIZE as usize];
+            unsafe { shell_link.GetDescription(&mut buffer)? };
+            let len = unsafe { u_strlen(buffer.as_ptr()) };
+            String::from_utf16_lossy(&buffer[..len as usize])
+        };
+        let args = description.split('\n').map(PathBuf::from).collect();
+
+        removed.push(args);
+    }
+
+    Ok((list, removed))
+}
+
+fn add_dock_menu(list: &ICustomDestinationList, dock_menus: &[DockMenuItem]) -> anyhow::Result<()> {
+    unsafe {
+        let tasks: IObjectCollection =
+            CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?;
+        for (idx, dock_menu) in dock_menus.iter().enumerate() {
+            let argument = HSTRING::from(format!("--dock-action {}", idx));
+            let description = HSTRING::from(dock_menu.description.as_str());
+            let display = dock_menu.name.as_str();
+            let task = create_shell_link(argument, description, None, display)?;
+            tasks.AddObject(&task)?;
+        }
+        list.AddUserTasks(&tasks)?;
+        Ok(())
+    }
+}
+
+fn add_recent_folders(
+    list: &ICustomDestinationList,
+    entries: &[SmallVec<[PathBuf; 2]>],
+    removed: &Vec<SmallVec<[PathBuf; 2]>>,
+) -> anyhow::Result<()> {
+    unsafe {
+        let tasks: IObjectCollection =
+            CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?;
+
+        for folder_path in entries
+            .iter()
+            .filter(|path| !is_item_in_array(path, removed))
+        {
+            let argument = HSTRING::from(
+                folder_path
+                    .iter()
+                    .map(|path| format!("\"{}\"", path.display()))
+                    .join(" "),
+            );
+
+            let description = HSTRING::from(
+                folder_path
+                    .iter()
+                    .map(|path| path.to_string_lossy())
+                    .collect::<Vec<_>>()
+                    .join("\n"),
+            );
+            // simulate folder icon
+            // https://github.com/microsoft/vscode/blob/7a5dc239516a8953105da34f84bae152421a8886/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts#L380
+            let icon = HSTRING::from("explorer.exe");
+
+            let display = folder_path
+                .iter()
+                .map(|p| {
+                    p.file_name()
+                        .map(|name| name.to_string_lossy().to_string())
+                        .unwrap_or_else(|| p.to_string_lossy().to_string())
+                })
+                .join(", ");
+
+            tasks.AddObject(&create_shell_link(
+                argument,
+                description,
+                Some(icon),
+                &display,
+            )?)?;
+        }
+
+        list.AppendCategory(&HSTRING::from("Recent Folders"), &tasks)?;
+        Ok(())
+    }
+}
+
+#[inline]
+fn is_item_in_array(item: &SmallVec<[PathBuf; 2]>, removed: &Vec<SmallVec<[PathBuf; 2]>>) -> bool {
+    removed.iter().any(|removed_item| removed_item == item)
+}
+
+fn create_shell_link(
+    argument: HSTRING,
+    description: HSTRING,
+    icon: Option<HSTRING>,
+    display: &str,
+) -> anyhow::Result<IShellLinkW> {
+    unsafe {
+        let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
+        let exe_path = HSTRING::from(std::env::current_exe()?.as_os_str());
+        link.SetPath(&exe_path)?;
+        link.SetArguments(&argument)?;
+        link.SetDescription(&description)?;
+        if let Some(icon) = icon {
+            link.SetIconLocation(&icon, 0)?;
+        }
+        let store: IPropertyStore = link.cast()?;
+        let title = PROPVARIANT::from(display);
+        store.SetValue(&PKEY_TITLE, &title)?;
+        store.Commit()?;
+
+        Ok(link)
+    }
+}

crates/gpui/src/platform/windows/platform.rs 🔗

@@ -14,10 +14,7 @@ use itertools::Itertools;
 use parking_lot::RwLock;
 use smallvec::SmallVec;
 use windows::{
-    UI::{
-        StartScreen::{JumpList, JumpListItem},
-        ViewManagement::UISettings,
-    },
+    UI::ViewManagement::UISettings,
     Win32::{
         Foundation::*,
         Graphics::{
@@ -52,7 +49,7 @@ pub(crate) struct WindowsPlatform {
 pub(crate) struct WindowsPlatformState {
     callbacks: PlatformCallbacks,
     menus: Vec<OwnedMenu>,
-    dock_menu_actions: Vec<Box<dyn Action>>,
+    jump_list: JumpList,
     // NOTE: standard cursor handles don't need to close.
     pub(crate) current_cursor: Option<HCURSOR>,
 }
@@ -70,12 +67,12 @@ struct PlatformCallbacks {
 impl WindowsPlatformState {
     fn new() -> Self {
         let callbacks = PlatformCallbacks::default();
-        let dock_menu_actions = Vec::new();
+        let jump_list = JumpList::new();
         let current_cursor = load_cursor(CursorStyle::Arrow);
 
         Self {
             callbacks,
-            dock_menu_actions,
+            jump_list,
             current_cursor,
             menus: Vec::new(),
         }
@@ -189,9 +186,10 @@ impl WindowsPlatform {
         let mut lock = self.state.borrow_mut();
         if let Some(mut callback) = lock.callbacks.app_menu_action.take() {
             let Some(action) = lock
-                .dock_menu_actions
+                .jump_list
+                .dock_menus
                 .get(action_idx)
-                .map(|action| action.boxed_clone())
+                .map(|dock_menu| dock_menu.action.boxed_clone())
             else {
                 lock.callbacks.app_menu_action = Some(callback);
                 log::error!("Dock menu for index {action_idx} not found");
@@ -254,33 +252,35 @@ impl WindowsPlatform {
         false
     }
 
-    fn configure_jump_list(&self, menus: Vec<MenuItem>) -> Result<()> {
-        let jump_list = JumpList::LoadCurrentAsync()?.get()?;
-        let items = jump_list.Items()?;
-        items.Clear()?;
+    fn set_dock_menus(&self, menus: Vec<MenuItem>) {
         let mut actions = Vec::new();
-        for item in menus.into_iter() {
-            let item = match item {
-                MenuItem::Separator => JumpListItem::CreateSeparator()?,
-                MenuItem::Submenu(_) => {
-                    log::error!("Set `MenuItemSubmenu` for dock menu on Windows is not supported.");
-                    continue;
-                }
-                MenuItem::Action { name, action, .. } => {
-                    let idx = actions.len();
-                    actions.push(action.boxed_clone());
-                    let item_args = format!("--dock-action {}", idx);
-                    JumpListItem::CreateWithArguments(
-                        &HSTRING::from(item_args),
-                        &HSTRING::from(name.as_ref()),
-                    )?
-                }
-            };
-            items.Append(&item)?;
-        }
-        jump_list.SaveAsync()?.get()?;
-        self.state.borrow_mut().dock_menu_actions = actions;
-        Ok(())
+        menus.into_iter().for_each(|menu| {
+            if let Some(dock_menu) = DockMenuItem::new(menu).log_err() {
+                actions.push(dock_menu);
+            }
+        });
+        let mut lock = self.state.borrow_mut();
+        lock.jump_list.dock_menus = actions;
+        update_jump_list(&lock.jump_list).log_err();
+    }
+
+    fn update_jump_list(
+        &self,
+        menus: Vec<MenuItem>,
+        entries: Vec<SmallVec<[PathBuf; 2]>>,
+    ) -> Vec<SmallVec<[PathBuf; 2]>> {
+        let mut actions = Vec::new();
+        menus.into_iter().for_each(|menu| {
+            if let Some(dock_menu) = DockMenuItem::new(menu).log_err() {
+                actions.push(dock_menu);
+            }
+        });
+        let mut lock = self.state.borrow_mut();
+        lock.jump_list.dock_menus = actions;
+        lock.jump_list.recent_workspaces = entries;
+        update_jump_list(&lock.jump_list)
+            .log_err()
+            .unwrap_or_default()
     }
 }
 
@@ -535,7 +535,7 @@ impl Platform for WindowsPlatform {
     }
 
     fn set_dock_menu(&self, menus: Vec<MenuItem>, _keymap: &Keymap) {
-        self.configure_jump_list(menus).log_err();
+        self.set_dock_menus(menus);
     }
 
     fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>) {
@@ -673,6 +673,14 @@ impl Platform for WindowsPlatform {
             .log_err();
         }
     }
+
+    fn update_jump_list(
+        &self,
+        menus: Vec<MenuItem>,
+        entries: Vec<SmallVec<[PathBuf; 2]>>,
+    ) -> Vec<SmallVec<[PathBuf; 2]>> {
+        self.update_jump_list(menus, entries)
+    }
 }
 
 impl Drop for WindowsPlatform {

crates/recent_projects/src/recent_projects.rs 🔗

@@ -24,8 +24,8 @@ use std::{
 use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
 use util::{ResultExt, paths::PathExt};
 use workspace::{
-    CloseIntent, ModalView, OpenOptions, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace,
-    WorkspaceId,
+    CloseIntent, HistoryManager, ModalView, OpenOptions, SerializedWorkspaceLocation, WORKSPACE_DB,
+    Workspace, WorkspaceId,
 };
 use zed_actions::{OpenRecent, OpenRemote};
 
@@ -553,7 +553,13 @@ impl RecentProjectsDelegate {
                         .delegate
                         .set_selected_index(ix.saturating_sub(1), window, cx);
                     picker.delegate.reset_selected_match_index = false;
-                    picker.update_matches(picker.query(cx), window, cx)
+                    picker.update_matches(picker.query(cx), window, cx);
+                    // After deleting a project, we want to update the history manager to reflect the change.
+                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
+                    if let Some(history_manager) = HistoryManager::global(cx) {
+                        history_manager
+                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
+                    }
                 })
             })
             .detach();

crates/workspace/Cargo.toml 🔗

@@ -67,6 +67,9 @@ uuid.workspace = true
 zed_actions.workspace = true
 workspace-hack.workspace = true
 
+[target.'cfg(target_os = "windows")'.dependencies]
+windows.workspace = true
+
 [dev-dependencies]
 call = { workspace = true, features = ["test-support"] }
 client = { workspace = true, features = ["test-support"] }
@@ -78,5 +81,5 @@ gpui = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
 session = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
-http_client =  { workspace = true, features = ["test-support"] }
+http_client = { workspace = true, features = ["test-support"] }
 tempfile.workspace = true

crates/workspace/src/history_manager.rs 🔗

@@ -0,0 +1,129 @@
+use std::path::PathBuf;
+
+use gpui::{AppContext, Entity, Global, MenuItem};
+use smallvec::SmallVec;
+use ui::App;
+use util::{ResultExt, paths::PathExt};
+
+use crate::{NewWindow, SerializedWorkspaceLocation, WORKSPACE_DB, WorkspaceId};
+
+pub fn init(cx: &mut App) {
+    let manager = cx.new(|_| HistoryManager::new());
+    HistoryManager::set_global(manager.clone(), cx);
+    HistoryManager::init(manager, cx);
+}
+
+pub struct HistoryManager {
+    /// The history of workspaces that have been opened in the past, in reverse order.
+    /// The most recent workspace is at the end of the vector.
+    history: Vec<HistoryManagerEntry>,
+}
+
+#[derive(Debug)]
+pub struct HistoryManagerEntry {
+    pub id: WorkspaceId,
+    pub path: SmallVec<[PathBuf; 2]>,
+}
+
+struct GlobalHistoryManager(Entity<HistoryManager>);
+
+impl Global for GlobalHistoryManager {}
+
+impl HistoryManager {
+    fn new() -> Self {
+        Self {
+            history: Vec::new(),
+        }
+    }
+
+    fn init(this: Entity<HistoryManager>, cx: &App) {
+        cx.spawn(async move |cx| {
+            let recent_folders = WORKSPACE_DB
+                .recent_workspaces_on_disk()
+                .await
+                .unwrap_or_default()
+                .into_iter()
+                .rev()
+                .map(|(id, location)| HistoryManagerEntry::new(id, &location))
+                .collect::<Vec<_>>();
+            this.update(cx, |this, cx| {
+                this.history = recent_folders;
+                this.update_jump_list(cx);
+            })
+        })
+        .detach();
+    }
+
+    pub fn global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalHistoryManager>()
+            .map(|model| model.0.clone())
+    }
+
+    fn set_global(history_manager: Entity<Self>, cx: &mut App) {
+        cx.set_global(GlobalHistoryManager(history_manager));
+    }
+
+    pub fn update_history(&mut self, id: WorkspaceId, entry: HistoryManagerEntry, cx: &App) {
+        if let Some(pos) = self.history.iter().position(|e| e.id == id) {
+            self.history.remove(pos);
+        }
+        self.history.push(entry);
+        self.update_jump_list(cx);
+    }
+
+    pub fn delete_history(&mut self, id: WorkspaceId, cx: &App) {
+        let Some(pos) = self.history.iter().position(|e| e.id == id) else {
+            return;
+        };
+        self.history.remove(pos);
+        self.update_jump_list(cx);
+    }
+
+    fn update_jump_list(&mut self, cx: &App) {
+        let menus = vec![MenuItem::action("New Window", NewWindow)];
+        let entries = self
+            .history
+            .iter()
+            .rev()
+            .map(|entry| entry.path.clone())
+            .collect::<Vec<_>>();
+        let user_removed = cx.update_jump_list(menus, entries);
+        self.remove_user_removed_workspaces(user_removed, cx);
+    }
+
+    pub fn remove_user_removed_workspaces(
+        &mut self,
+        user_removed: Vec<SmallVec<[PathBuf; 2]>>,
+        cx: &App,
+    ) {
+        if user_removed.is_empty() {
+            return;
+        }
+        let mut deleted_ids = Vec::new();
+        for idx in (0..self.history.len()).rev() {
+            if let Some(entry) = self.history.get(idx) {
+                if user_removed.contains(&entry.path) {
+                    deleted_ids.push(entry.id);
+                    self.history.remove(idx);
+                }
+            }
+        }
+        cx.spawn(async move |_| {
+            for id in deleted_ids.iter() {
+                WORKSPACE_DB.delete_workspace_by_id(*id).await.log_err();
+            }
+        })
+        .detach();
+    }
+}
+
+impl HistoryManagerEntry {
+    pub fn new(id: WorkspaceId, location: &SerializedWorkspaceLocation) -> Self {
+        let path = location
+            .sorted_paths()
+            .iter()
+            .map(|path| path.compact())
+            .collect::<SmallVec<[PathBuf; 2]>>();
+        Self { id, path }
+    }
+}

crates/workspace/src/persistence.rs 🔗

@@ -745,7 +745,7 @@ impl WorkspaceDb {
                 conn.exec_bound(sql!(
                     DELETE FROM pane_groups WHERE workspace_id = ?1;
                     DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id)
-                .context("Clearing old panes")?;
+                    .context("Clearing old panes")?;
 
                 conn.exec_bound(sql!(DELETE FROM breakpoints WHERE workspace_id = ?1))?(workspace.id).context("Clearing old breakpoints")?;
 

crates/workspace/src/workspace.rs 🔗

@@ -1,4 +1,5 @@
 pub mod dock;
+pub mod history_manager;
 pub mod item;
 mod modal_layer;
 pub mod notifications;
@@ -43,6 +44,7 @@ use gpui::{
     WindowHandle, WindowId, WindowOptions, action_as, actions, canvas, impl_action_as,
     impl_actions, point, relative, size, transparent_black,
 };
+pub use history_manager::*;
 pub use item::{
     FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, PreviewTabsSettings,
     ProjectItem, SerializableItem, SerializableItemHandle, WeakItemHandle,
@@ -387,6 +389,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
     component::init();
     theme_preview::init(cx);
     toast_layer::init(cx);
+    history_manager::init(cx);
 
     cx.on_action(Workspace::close_global);
     cx.on_action(reload);
@@ -902,6 +905,9 @@ impl Workspace {
                 project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => {
                     this.update_window_title(window, cx);
                     this.serialize_workspace(window, cx);
+                    // This event could be triggered by `AddFolderToProject` or `RemoveFromProject`.
+                    // So we need to update the history.
+                    this.update_history(cx);
                 }
 
                 project::Event::DisconnectedFromHost => {
@@ -1334,7 +1340,10 @@ impl Workspace {
                 .unwrap_or_default();
 
             window
-                .update(cx, |_, window, _| window.activate_window())
+                .update(cx, |workspace, window, cx| {
+                    window.activate_window();
+                    workspace.update_history(cx);
+                })
                 .log_err();
             Ok((window, opened_items))
         })
@@ -4707,19 +4716,7 @@ impl Workspace {
             }
         }
 
-        let location = if let Some(ssh_project) = &self.serialized_ssh_project {
-            Some(SerializedWorkspaceLocation::Ssh(ssh_project.clone()))
-        } else if let Some(local_paths) = self.local_paths(cx) {
-            if !local_paths.is_empty() {
-                Some(SerializedWorkspaceLocation::from_local_paths(local_paths))
-            } else {
-                None
-            }
-        } else {
-            None
-        };
-
-        if let Some(location) = location {
+        if let Some(location) = self.serialize_workspace_location(cx) {
             let breakpoints = self.project.update(cx, |project, cx| {
                 project.breakpoint_store().read(cx).all_breakpoints(cx)
             });
@@ -4739,13 +4736,42 @@ impl Workspace {
                 breakpoints,
                 window_id: Some(window.window_handle().window_id().as_u64()),
             };
+
             return window.spawn(cx, async move |_| {
-                persistence::DB.save_workspace(serialized_workspace).await
+                persistence::DB.save_workspace(serialized_workspace).await;
             });
         }
         Task::ready(())
     }
 
+    fn serialize_workspace_location(&self, cx: &App) -> Option<SerializedWorkspaceLocation> {
+        if let Some(ssh_project) = &self.serialized_ssh_project {
+            Some(SerializedWorkspaceLocation::Ssh(ssh_project.clone()))
+        } else if let Some(local_paths) = self.local_paths(cx) {
+            if !local_paths.is_empty() {
+                Some(SerializedWorkspaceLocation::from_local_paths(local_paths))
+            } else {
+                None
+            }
+        } else {
+            None
+        }
+    }
+
+    fn update_history(&self, cx: &mut App) {
+        let Some(id) = self.database_id() else {
+            return;
+        };
+        let Some(location) = self.serialize_workspace_location(cx) else {
+            return;
+        };
+        if let Some(manager) = HistoryManager::global(cx) {
+            manager.update(cx, |this, cx| {
+                this.update_history(id, HistoryManagerEntry::new(id, &location), cx);
+            });
+        }
+    }
+
     async fn serialize_items(
         this: &WeakEntity<Self>,
         items_rx: UnboundedReceiver<Box<dyn SerializableItemHandle>>,
@@ -6614,6 +6640,7 @@ async fn open_ssh_project_inner(
             let mut workspace =
                 Workspace::new(Some(workspace_id), project, app_state.clone(), window, cx);
             workspace.set_serialized_ssh_project(serialized_ssh_project);
+            workspace.update_history(cx);
             workspace
         });
     })?;

crates/zed/src/zed.rs 🔗

@@ -26,9 +26,9 @@ use git_ui::git_panel::GitPanel;
 use git_ui::project_diff::ProjectDiffToolbar;
 use gpui::{
     Action, App, AppContext as _, AsyncWindowContext, Context, DismissEvent, Element, Entity,
-    Focusable, KeyBinding, MenuItem, ParentElement, PathPromptOptions, PromptLevel, ReadGlobal,
-    SharedString, Styled, Task, TitlebarOptions, UpdateGlobal, Window, WindowKind, WindowOptions,
-    actions, point, px,
+    Focusable, KeyBinding, ParentElement, PathPromptOptions, PromptLevel, ReadGlobal, SharedString,
+    Styled, Task, TitlebarOptions, UpdateGlobal, Window, WindowKind, WindowOptions, actions, point,
+    px,
 };
 use image_viewer::ImageInfo;
 use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType};
@@ -1386,7 +1386,12 @@ fn reload_keymaps(cx: &mut App, user_key_bindings: Vec<KeyBinding>) {
     load_default_keymap(cx);
     cx.bind_keys(user_key_bindings);
     cx.set_menus(app_menus());
-    cx.set_dock_menu(vec![MenuItem::action("New Window", workspace::NewWindow)]);
+    // On Windows, this is set in the `update_jump_list` method of the `HistoryManager`.
+    #[cfg(not(target_os = "windows"))]
+    cx.set_dock_menu(vec![gpui::MenuItem::action(
+        "New Window",
+        workspace::NewWindow,
+    )]);
 }
 
 pub fn load_default_keymap(cx: &mut App) {