Archive closed browser tabs

This commit is contained in:
2026-05-07 19:53:32 -04:00
parent 82df2b4502
commit e0eb5b51b2
9 changed files with 150 additions and 5 deletions
+5
View File
@@ -14,6 +14,7 @@ actions!(
FocusAddressBar, FocusAddressBar,
OpenNewTab, OpenNewTab,
Quit, Quit,
RestoreClosedTab,
SelectNextTab, SelectNextTab,
SelectPreviousTab, SelectPreviousTab,
ToggleFavoriteTab, ToggleFavoriteTab,
@@ -32,6 +33,8 @@ fn main() {
KeyBinding::new("ctrl-l", FocusAddressBar, None), KeyBinding::new("ctrl-l", FocusAddressBar, None),
KeyBinding::new("cmd-w", CloseCurrentTab, None), KeyBinding::new("cmd-w", CloseCurrentTab, None),
KeyBinding::new("ctrl-w", CloseCurrentTab, None), KeyBinding::new("ctrl-w", CloseCurrentTab, None),
KeyBinding::new("cmd-shift-t", RestoreClosedTab, None),
KeyBinding::new("ctrl-shift-t", RestoreClosedTab, None),
KeyBinding::new("cmd-shift-f", ToggleFavoriteTab, None), KeyBinding::new("cmd-shift-f", ToggleFavoriteTab, None),
KeyBinding::new("ctrl-shift-f", ToggleFavoriteTab, None), KeyBinding::new("ctrl-shift-f", ToggleFavoriteTab, None),
KeyBinding::new("cmd-shift-p", TogglePinnedTab, None), KeyBinding::new("cmd-shift-p", TogglePinnedTab, None),
@@ -58,6 +61,8 @@ fn main() {
MenuItem::separator(), MenuItem::separator(),
MenuItem::action("Close Tab", CloseCurrentTab), MenuItem::action("Close Tab", CloseCurrentTab),
MenuItem::separator(), MenuItem::separator(),
MenuItem::action("Restore Closed Tab", RestoreClosedTab),
MenuItem::separator(),
MenuItem::action("Toggle Pin", TogglePinnedTab), MenuItem::action("Toggle Pin", TogglePinnedTab),
], ],
}, },
+20 -2
View File
@@ -6,8 +6,8 @@ use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscriptio
use gpui_component::input::{InputEvent, InputState, SelectAll}; use gpui_component::input::{InputEvent, InputState, SelectAll};
use crate::{ use crate::{
CloseCurrentTab, FocusAddressBar, OpenNewTab, SelectNextTab, SelectPreviousTab, CloseCurrentTab, FocusAddressBar, OpenNewTab, RestoreClosedTab, SelectNextTab,
ToggleFavoriteTab, TogglePinnedTab, SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab,
}; };
enum ShellState { enum ShellState {
@@ -133,6 +133,15 @@ impl ElyShell {
} }
} }
fn restore_closed_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let ShellState::Ready(core) = &mut self.state
&& core.restore_last_archived_tab().is_ok()
{
self.sync_address_input(window, cx);
cx.notify();
}
}
fn toggle_active_tab_favorite(&mut self, cx: &mut Context<Self>) { fn toggle_active_tab_favorite(&mut self, cx: &mut Context<Self>) {
if let ShellState::Ready(core) = &mut self.state if let ShellState::Ready(core) = &mut self.state
&& core.toggle_active_tab_favorite().is_ok() && core.toggle_active_tab_favorite().is_ok()
@@ -171,6 +180,15 @@ impl ElyShell {
self.open_new_tab(window, cx); self.open_new_tab(window, cx);
} }
fn on_restore_closed_tab(
&mut self,
_: &RestoreClosedTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.restore_closed_tab(window, cx);
}
fn on_select_next_tab( fn on_select_next_tab(
&mut self, &mut self,
_: &SelectNextTab, _: &SelectNextTab,
+1
View File
@@ -38,6 +38,7 @@ impl ElyShell {
.on_action(cx.listener(Self::on_close_current_tab)) .on_action(cx.listener(Self::on_close_current_tab))
.on_action(cx.listener(Self::on_focus_address_bar)) .on_action(cx.listener(Self::on_focus_address_bar))
.on_action(cx.listener(Self::on_open_new_tab)) .on_action(cx.listener(Self::on_open_new_tab))
.on_action(cx.listener(Self::on_restore_closed_tab))
.on_action(cx.listener(Self::on_select_next_tab)) .on_action(cx.listener(Self::on_select_next_tab))
.on_action(cx.listener(Self::on_select_previous_tab)) .on_action(cx.listener(Self::on_select_previous_tab))
.on_action(cx.listener(Self::on_toggle_favorite_tab)) .on_action(cx.listener(Self::on_toggle_favorite_tab))
+3
View File
@@ -12,6 +12,9 @@ pub enum CoreError {
#[error("favorite limit reached: {limit}")] #[error("favorite limit reached: {limit}")]
FavoriteLimitReached { limit: usize }, FavoriteLimitReached { limit: usize },
#[error("browser state has no archived tabs")]
NoArchivedTabs,
#[error("browser state has no active tab")] #[error("browser state has no active tab")]
MissingActiveTab, MissingActiveTab,
} }
+27 -3
View File
@@ -1,6 +1,6 @@
use ely_domain::{ use ely_domain::{
BrowserTab, CommandIntent, CommandScope, DomainError, Profile, ProfileId, ProfileKind, Space, ArchiveSource, ArchivedTab, BrowserTab, CommandIntent, CommandScope, DomainError, Profile,
SpaceId, TabId, UrlText, ProfileId, ProfileKind, Space, SpaceId, TabId, UrlText,
}; };
use url::Url; use url::Url;
@@ -33,6 +33,7 @@ pub struct BrowserSnapshot {
pub tabs: Vec<BrowserTab>, pub tabs: Vec<BrowserTab>,
pub favorites: Vec<BrowserTab>, pub favorites: Vec<BrowserTab>,
pub pinned_tabs: Vec<BrowserTab>, pub pinned_tabs: Vec<BrowserTab>,
pub archived_tabs: Vec<ArchivedTab>,
pub active_tab_id: TabId, pub active_tab_id: TabId,
pub active_space_name: String, pub active_space_name: String,
pub active_profile_name: String, pub active_profile_name: String,
@@ -44,6 +45,7 @@ pub struct BrowserCore {
spaces: Vec<Space>, spaces: Vec<Space>,
profiles: Vec<Profile>, profiles: Vec<Profile>,
tabs: Vec<BrowserTab>, tabs: Vec<BrowserTab>,
archived_tabs: Vec<ArchivedTab>,
active_space_id: SpaceId, active_space_id: SpaceId,
active_profile_id: ProfileId, active_profile_id: ProfileId,
active_tab_id: TabId, active_tab_id: TabId,
@@ -71,6 +73,7 @@ impl BrowserCore {
spaces: vec![space], spaces: vec![space],
profiles: vec![profile], profiles: vec![profile],
tabs: vec![tab], tabs: vec![tab],
archived_tabs: Vec::new(),
command_query: String::new(), command_query: String::new(),
new_tab_url, new_tab_url,
}) })
@@ -102,7 +105,8 @@ impl BrowserCore {
.ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?;
let was_active = &self.active_tab_id == tab_id; let was_active = &self.active_tab_id == tab_id;
self.tabs.remove(close_index); let closed_tab = self.tabs.remove(close_index);
self.archived_tabs.push(ArchivedTab::new(closed_tab, ArchiveSource::ManualClose));
if self.tabs.is_empty() { if self.tabs.is_empty() {
let tab = self.build_tab(self.new_tab_url.clone()); let tab = self.build_tab(self.new_tab_url.clone());
@@ -121,6 +125,21 @@ impl BrowserCore {
Ok(self.active_tab_id.clone()) Ok(self.active_tab_id.clone())
} }
pub fn restore_last_archived_tab(&mut self) -> Result<TabId, CoreError> {
let archived_tab = self.archived_tabs.pop().ok_or(CoreError::NoArchivedTabs)?;
let tab = archived_tab.into_tab();
let tab_id = tab.id().clone();
let insert_index = self
.tabs
.iter()
.position(|existing| existing.id() == &self.active_tab_id)
.map_or(self.tabs.len(), |index| index + 1);
self.tabs.insert(insert_index, tab);
self.select_tab(&tab_id)?;
Ok(tab_id)
}
pub fn select_tab(&mut self, tab_id: &TabId) -> Result<(), CoreError> { pub fn select_tab(&mut self, tab_id: &TabId) -> Result<(), CoreError> {
let tab = self let tab = self
.tabs .tabs
@@ -221,6 +240,7 @@ impl BrowserCore {
Ok(BrowserSnapshot { Ok(BrowserSnapshot {
favorites: self.favorites(), favorites: self.favorites(),
pinned_tabs: self.pinned_tabs(), pinned_tabs: self.pinned_tabs(),
archived_tabs: self.archived_tabs.clone(),
tabs: self.tabs.clone(), tabs: self.tabs.clone(),
active_tab_id: self.active_tab_id.clone(), active_tab_id: self.active_tab_id.clone(),
active_space_name: active_space.name().to_string(), active_space_name: active_space.name().to_string(),
@@ -263,6 +283,10 @@ impl BrowserCore {
self.toggle_active_tab_pinned()?; self.toggle_active_tab_pinned()?;
Ok(true) Ok(true)
} }
"restore-tab" | "reopen-tab" => {
self.restore_last_archived_tab()?;
Ok(true)
}
_ => Ok(false), _ => Ok(false),
} }
} }
+17
View File
@@ -67,6 +67,23 @@ fn close_tab_command_closes_active_tab() -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
#[test]
fn restore_tab_command_reopens_last_archived_tab() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let closed_tab_id = core.open_tab(UrlText::parse("https://example.com")?);
core.close_active_tab()?;
core.set_command_query(">restore-tab");
let intent = core.submit_command()?;
let snapshot = core.snapshot()?;
assert_eq!(intent, Some(CommandIntent::Command("restore-tab".to_string())));
assert_eq!(snapshot.active_tab_id, closed_tab_id);
assert!(snapshot.archived_tabs.is_empty());
assert_eq!(snapshot.command_query, "");
Ok(())
}
#[test] #[test]
fn unknown_command_preserves_query() -> Result<(), Box<dyn Error>> { fn unknown_command_preserves_query() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
+32
View File
@@ -36,6 +36,8 @@ fn closes_active_tab_and_selects_next_neighbor() -> Result<(), Box<dyn Error>> {
assert_eq!(active_tab_id, third_tab_id); assert_eq!(active_tab_id, third_tab_id);
assert_eq!(ordered_ids, vec![first_tab_id, active_tab_id.clone()]); assert_eq!(ordered_ids, vec![first_tab_id, active_tab_id.clone()]);
assert_eq!(snapshot.active_tab_id, active_tab_id); assert_eq!(snapshot.active_tab_id, active_tab_id);
assert_eq!(snapshot.archived_tabs.len(), 1);
assert_eq!(snapshot.archived_tabs[0].tab().id(), &second_tab_id);
Ok(()) Ok(())
} }
@@ -49,11 +51,41 @@ fn closing_last_tab_replaces_it_with_new_tab() -> Result<(), Box<dyn Error>> {
let snapshot = core.snapshot()?; let snapshot = core.snapshot()?;
assert_eq!(snapshot.tabs.len(), 1); assert_eq!(snapshot.tabs.len(), 1);
assert_eq!(snapshot.active_tab_id, active_tab_id); assert_eq!(snapshot.active_tab_id, active_tab_id);
assert_eq!(snapshot.archived_tabs.len(), 1);
let replacement_tab = snapshot.tabs.first().ok_or(CoreError::MissingActiveTab)?; let replacement_tab = snapshot.tabs.first().ok_or(CoreError::MissingActiveTab)?;
assert_eq!(replacement_tab.url().as_str(), "ely://new-tab"); assert_eq!(replacement_tab.url().as_str(), "ely://new-tab");
Ok(()) Ok(())
} }
#[test]
fn restores_last_archived_tab() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let closed_tab_id = core.open_tab(UrlText::parse("https://example.com")?);
core.close_active_tab()?;
let restored_tab_id = core.restore_last_archived_tab()?;
let snapshot = core.snapshot()?;
assert_eq!(restored_tab_id, closed_tab_id);
assert_eq!(snapshot.active_tab_id, closed_tab_id);
assert!(snapshot.archived_tabs.is_empty());
assert!(snapshot.tabs.iter().any(|tab| tab.id() == &closed_tab_id));
Ok(())
}
#[test]
fn restore_without_archived_tabs_returns_error() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
let error = match core.restore_last_archived_tab() {
Err(error) => error,
Ok(_) => return Err("restore should require an archived tab".into()),
};
assert_eq!(error, CoreError::NoArchivedTabs);
Ok(())
}
#[test] #[test]
fn selects_next_tab_with_wraparound() -> Result<(), Box<dyn Error>> { fn selects_next_tab_with_wraparound() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
+43
View File
@@ -0,0 +1,43 @@
use std::time::SystemTime;
use crate::BrowserTab;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ArchiveSource {
ManualClose,
AutoArchive,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArchivedTab {
tab: BrowserTab,
archived_at: SystemTime,
source: ArchiveSource,
}
impl ArchivedTab {
#[must_use]
pub fn new(tab: BrowserTab, source: ArchiveSource) -> Self {
Self { tab, archived_at: SystemTime::now(), source }
}
#[must_use]
pub fn tab(&self) -> &BrowserTab {
&self.tab
}
#[must_use]
pub fn archived_at(&self) -> SystemTime {
self.archived_at
}
#[must_use]
pub fn source(&self) -> &ArchiveSource {
&self.source
}
#[must_use]
pub fn into_tab(self) -> BrowserTab {
self.tab
}
}
+2
View File
@@ -1,3 +1,4 @@
mod archive;
mod command; mod command;
mod error; mod error;
mod identifiers; mod identifiers;
@@ -7,6 +8,7 @@ mod split;
mod tab; mod tab;
mod url_text; mod url_text;
pub use archive::{ArchiveSource, ArchivedTab};
pub use command::{CommandIntent, CommandScope}; pub use command::{CommandIntent, CommandScope};
pub use error::DomainError; pub use error::DomainError;
pub use identifiers::{ProfileId, SpaceId, SplitId, TabId, WebViewId}; pub use identifiers::{ProfileId, SpaceId, SplitId, TabId, WebViewId};