Archive closed browser tabs
This commit is contained in:
@@ -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),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()?)?;
|
||||||
|
|||||||
@@ -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()?)?;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
Reference in New Issue
Block a user