From b349cfc012e1e814907ea90bb8af62ee522cf4bd Mon Sep 17 00:00:00 2001 From: Brian Clifton Date: Wed, 20 May 2026 09:14:00 -0700 Subject: [PATCH] Workspaces - phase 1 (#36059) * Introduce a workspace service for serializing open windows/tabs. --------- Co-authored-by: Brian Johnson <34129+bridiver@users.noreply.github.com> --- app/brave_command_ids.h | 4 + app/brave_generated_resources.grd | 18 ++ app/generate_command_metadata.py | 2 + browser/about_flags.cc | 15 + ...browser_context_keyed_service_factories.cc | 5 + browser/sources.gni | 2 + browser/ui/BUILD.gn | 1 + .../ui/brave_browser_command_controller.cc | 37 +++ browser/ui/brave_browser_command_controller.h | 1 + browser/ui/toolbar/brave_app_menu_model.cc | 19 ++ browser/workspaces/BUILD.gn | 61 ++++ browser/workspaces/features.cc | 15 + browser/workspaces/features.h | 17 ++ browser/workspaces/pref_names.h | 12 + browser/workspaces/workspace_metadata.h | 22 ++ browser/workspaces/workspace_service.cc | 141 +++++++++ browser/workspaces/workspace_service.h | 100 +++++++ .../workspaces/workspace_service_factory.cc | 53 ++++ .../workspaces/workspace_service_factory.h | 41 +++ .../workspaces/workspace_service_unittest.cc | 275 ++++++++++++++++++ browser/workspaces/workspace_session_utils.cc | 231 +++++++++++++++ browser/workspaces/workspace_session_utils.h | 35 +++ browser/workspaces/workspace_utils.cc | 92 ++++++ browser/workspaces/workspace_utils.h | 60 ++++ test/BUILD.gn | 1 + 25 files changed, 1260 insertions(+) create mode 100644 browser/workspaces/BUILD.gn create mode 100644 browser/workspaces/features.cc create mode 100644 browser/workspaces/features.h create mode 100644 browser/workspaces/pref_names.h create mode 100644 browser/workspaces/workspace_metadata.h create mode 100644 browser/workspaces/workspace_service.cc create mode 100644 browser/workspaces/workspace_service.h create mode 100644 browser/workspaces/workspace_service_factory.cc create mode 100644 browser/workspaces/workspace_service_factory.h create mode 100644 browser/workspaces/workspace_service_unittest.cc create mode 100644 browser/workspaces/workspace_session_utils.cc create mode 100644 browser/workspaces/workspace_session_utils.h create mode 100644 browser/workspaces/workspace_utils.cc create mode 100644 browser/workspaces/workspace_utils.h diff --git a/app/brave_command_ids.h b/app/brave_command_ids.h index 06da528c3f3..ab2b4b381cd 100644 --- a/app/brave_command_ids.h +++ b/app/brave_command_ids.h @@ -154,6 +154,10 @@ #define IDC_TOGGLE_FOCUS_MODE 56457 #define IDC_NEW_TEMPORARY_CONTAINER 56458 +// Workspaces +#define IDC_SAVE_WORKSPACE 56459 +#define IDC_OPEN_WORKSPACE 56460 + #define IDC_BRAVE_COMMANDS_LAST 57000 #endif // BRAVE_APP_BRAVE_COMMAND_IDS_H_ diff --git a/app/brave_generated_resources.grd b/app/brave_generated_resources.grd index d6d25ca0937..22efd001544 100644 --- a/app/brave_generated_resources.grd +++ b/app/brave_generated_resources.grd @@ -253,6 +253,24 @@ Email Aliases + + + + Save Tabs as Workspace… + + + Open Workspace… + + + + + Save tabs as workspace… + + + Open workspace… + + + Install and run Google Widevine diff --git a/app/generate_command_metadata.py b/app/generate_command_metadata.py index bb857154f35..3bfe069eded 100644 --- a/app/generate_command_metadata.py +++ b/app/generate_command_metadata.py @@ -243,6 +243,8 @@ EXISTING_TRANSLATIONS = { "IDC_SHOW_BRAVE_WALLET": "IDS_SHOW_BRAVE_WALLET", "IDC_SHOW_BRAVE_WEBCOMPAT_REPORTER": "IDS_SHOW_BRAVE_WEBCOMPAT_REPORTER", "IDC_SHOW_EMAIL_ALIASES": "IDS_SHOW_EMAIL_ALIASES", + "IDC_SAVE_WORKSPACE": "IDS_SAVE_WORKSPACE", + "IDC_OPEN_WORKSPACE": "IDS_OPEN_WORKSPACE", "IDC_WINDOW_CLOSE_TABS_TO_LEFT": "IDS_TAB_CXMENU_CLOSETABSTOLEFT", "IDC_DEV_TOOLS_DEVICES": "IDS_DEV_TOOLS_DEVICES", # &Inspect devices "IDC_DEV_TOOLS": "IDS_DEV_TOOLS", # &Developer tools diff --git a/browser/about_flags.cc b/browser/about_flags.cc index 9bfd095dfba..4456ff48267 100644 --- a/browser/about_flags.cc +++ b/browser/about_flags.cc @@ -81,6 +81,7 @@ #include "chrome/browser/flags/android/chrome_feature_list.h" #else #include "brave/browser/ui/views/tabs/switches.h" +#include "brave/browser/workspaces/features.h" #include "brave/components/commander/common/features.h" #include "brave/components/commands/common/features.h" #endif @@ -795,6 +796,19 @@ constexpr flags_ui::FeatureEntry::Choice kVerticalTabCollapseDelayChoices[] = { FEATURE_VALUE_TYPE(email_aliases::features::kEmailAliases), \ })) +#if !BUILDFLAG(IS_ANDROID) +#define BRAVE_WORKSPACE_FEATURE_ENTRY \ + EXPAND_FEATURE_ENTRIES({ \ + "brave-workspace", \ + "Brave Workspace", \ + "Save sets of windows and tabs into loadable workspaces.", \ + kOsWin | kOsLinux | kOsMac, \ + FEATURE_VALUE_TYPE(features::kWorkspaces), \ + }) +#else +#define BRAVE_WORKSPACE_FEATURE_ENTRY +#endif + // Keep the last item empty. #define LAST_BRAVE_FEATURE_ENTRIES_ITEM @@ -1379,6 +1393,7 @@ constexpr flags_ui::FeatureEntry::Choice kVerticalTabCollapseDelayChoices[] = { PSST_FEATURE_ENTRIES \ BRAVE_FORCE_POPUP_TO_BE_OPENED_IN_NEW_TAB_FEATURE_ENTRY \ EMAIL_ALIASES_FEATURE_ENTRIES \ + BRAVE_WORKSPACE_FEATURE_ENTRY \ EXPAND_FEATURE_ENTRIES({ \ "brave-origin", \ "Enable Brave Origin", \ diff --git a/browser/browser_context_keyed_service_factories.cc b/browser/browser_context_keyed_service_factories.cc index 2aab2a421f3..b3ba67c6e36 100644 --- a/browser/browser_context_keyed_service_factories.cc +++ b/browser/browser_context_keyed_service_factories.cc @@ -68,6 +68,7 @@ #include "brave/browser/ui/bookmark/bookmark_prefs_service_factory.h" #include "brave/browser/ui/commands/accelerator_service_factory.h" #include "brave/browser/ui/tabs/shared_pinned_tab_service_factory.h" +#include "brave/browser/workspaces/workspace_service_factory.h" #include "brave/components/commands/common/features.h" #include "chrome/browser/ui/tabs/features.h" #else @@ -254,6 +255,10 @@ void EnsureBrowserContextKeyedServiceFactoriesBuilt() { #endif // BUILDFLAG(ENABLE_PSST) serp_metrics::SerpMetricsServiceFactory::GetInstance(); + +#if !BUILDFLAG(IS_ANDROID) + WorkspaceServiceFactory::GetInstance(); +#endif } } // namespace brave diff --git a/browser/sources.gni b/browser/sources.gni index 2982fdb8a8e..f415fbbfc6f 100644 --- a/browser/sources.gni +++ b/browser/sources.gni @@ -417,6 +417,8 @@ if (!is_android) { "//brave/browser/ui/webui/brave_new_tab_page_refresh", "//brave/browser/ui/webui/brave_new_tab_page_refresh:mojom", "//brave/browser/ui/webui/brave_welcome_page", + "//brave/browser/workspaces", + "//brave/browser/workspaces:impl", "//brave/ui/webui/brave_color_change_listener", "//chrome/browser/regional_capabilities", "//chrome/browser/ui/thumbnails", diff --git a/browser/ui/BUILD.gn b/browser/ui/BUILD.gn index 5b1522b2ed8..493bbee294d 100644 --- a/browser/ui/BUILD.gn +++ b/browser/ui/BUILD.gn @@ -412,6 +412,7 @@ source_set("ui") { "//brave/browser/ui/tabs:tab_strip", "//brave/browser/ui/webui/brave_welcome_page", "//brave/browser/ui/webui/settings/brave_account", + "//brave/browser/workspaces", "//brave/common", "//brave/components/brave_account", "//brave/components/brave_account:features", diff --git a/browser/ui/brave_browser_command_controller.cc b/browser/ui/brave_browser_command_controller.cc index dca08a9a7c9..89c09addcd4 100644 --- a/browser/ui/brave_browser_command_controller.cc +++ b/browser/ui/brave_browser_command_controller.cc @@ -20,6 +20,9 @@ #include "brave/browser/ui/browser_commands.h" #include "brave/browser/ui/focus_mode/focus_mode_utils.h" #include "brave/browser/ui/sidebar/sidebar_utils.h" +#include "brave/browser/workspaces/features.h" +#include "brave/browser/workspaces/workspace_service.h" +#include "brave/browser/workspaces/workspace_service_factory.h" #include "brave/components/ai_chat/core/common/buildflags/buildflags.h" #include "brave/components/brave_news/common/buildflags/buildflags.h" #include "brave/components/brave_rewards/core/rewards_util.h" @@ -369,6 +372,17 @@ void BraveBrowserCommandController::InitBraveCommandState() { ContainersServiceFactory::GetForProfile(browser_->profile())); #endif + // Reload options if person has an update in workspaces + if (base::FeatureList::IsEnabled(features::kWorkspaces) && + browser_->is_type_normal()) { + UpdateCommandForWorkspace(); + pref_change_registrar_.Add( + kWorkspacesMetadataPref, + base::BindRepeating( + &BraveBrowserCommandController::UpdateCommandForWorkspace, + base::Unretained(this))); + } + if (browser_->is_type_normal()) { // Delete these when upstream enables by default. UpdateCommandEnabled(IDC_READING_LIST_MENU, true); @@ -545,6 +559,17 @@ void BraveBrowserCommandController::UpdateCommandForSplitView() { } } +void BraveBrowserCommandController::UpdateCommandForWorkspace() { + auto* service = WorkspaceServiceFactory::GetForProfile(browser_->profile()); + + if (!service) { + return; + } + + UpdateCommandEnabled(IDC_SAVE_WORKSPACE, true); + UpdateCommandEnabled(IDC_OPEN_WORKSPACE, !service->ListWorkspaces().empty()); +} + void BraveBrowserCommandController::UpdateCommandForBraveSync() { UpdateCommandEnabled(IDC_SHOW_BRAVE_SYNC, true); } @@ -818,6 +843,18 @@ bool BraveBrowserCommandController::ExecuteBraveCommandWithDisposition( case IDC_TOGGLE_FOCUS_MODE: brave::ToggleFocusMode(base::to_address(browser_)); break; + case IDC_SAVE_WORKSPACE: + if (auto* svc = + WorkspaceServiceFactory::GetForProfile(browser_->profile())) { + svc->ShowSaveWorkspaceDialog(); + } + break; + case IDC_OPEN_WORKSPACE: + if (auto* svc = + WorkspaceServiceFactory::GetForProfile(browser_->profile())) { + svc->ShowOpenWorkspaceDialog(); + } + break; default: LOG(WARNING) << "Received Unimplemented Command: " << id; break; diff --git a/browser/ui/brave_browser_command_controller.h b/browser/ui/brave_browser_command_controller.h index eb353c63c9f..48255cabbb5 100644 --- a/browser/ui/brave_browser_command_controller.h +++ b/browser/ui/brave_browser_command_controller.h @@ -104,6 +104,7 @@ class BraveBrowserCommandController : public chrome::BrowserCommandController void UpdateCommandsForPin(); void UpdateCommandForFocusMode(); void UpdateCommandForSplitView(); + void UpdateCommandForWorkspace(); bool ExecuteBraveCommandWithDisposition(int id, WindowOpenDisposition disposition, diff --git a/browser/ui/toolbar/brave_app_menu_model.cc b/browser/ui/toolbar/brave_app_menu_model.cc index 2ecbc3227ef..3abffafe950 100644 --- a/browser/ui/toolbar/brave_app_menu_model.cc +++ b/browser/ui/toolbar/brave_app_menu_model.cc @@ -307,6 +307,25 @@ void BraveAppMenuModel::BuildMoreToolsSubMenu() { need_separator = false; } + // Save/Open workspaces + if (IsCommandIdEnabled(IDC_SAVE_WORKSPACE)) { + more_tools_menu_model->InsertItemWithStringIdAt( + next_target_index++, IDC_SAVE_WORKSPACE, IDS_SAVE_WORKSPACE); + need_separator = true; + } + + if (IsCommandIdEnabled(IDC_OPEN_WORKSPACE)) { + more_tools_menu_model->InsertItemWithStringIdAt( + next_target_index++, IDC_OPEN_WORKSPACE, IDS_OPEN_WORKSPACE); + need_separator = true; + } + + if (need_separator) { + more_tools_menu_model->InsertSeparatorAt(next_target_index++, + ui::NORMAL_SEPARATOR); + need_separator = false; + } + if (!browser()->profile()->IsOffTheRecord()) { if (auto index = more_tools_menu_model->GetIndexOfCommandId(IDC_NAME_WINDOW)) { diff --git a/browser/workspaces/BUILD.gn b/browser/workspaces/BUILD.gn new file mode 100644 index 00000000000..09c8548df62 --- /dev/null +++ b/browser/workspaces/BUILD.gn @@ -0,0 +1,61 @@ +# Copyright (c) 2026 The Brave Authors. All rights reserved. +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at https://mozilla.org/MPL/2.0/. + +source_set("impl") { + visibility = [ + "//brave/browser/workspaces:*", + "//chrome/browser", + ] + + sources = [ + "features.cc", + "workspace_service.cc", + "workspace_service_factory.cc", + "workspace_session_utils.cc", + "workspace_utils.cc", + ] + + deps = [ + ":workspaces", + "//base", + "//chrome/browser/ui", + "//chrome/browser/ui/tabs:tab_strip", + ] +} +source_set("workspaces") { + sources = [ + "features.h", + "pref_names.h", + "workspace_metadata.h", + "workspace_service.h", + "workspace_service_factory.h", + "workspace_session_utils.h", + "workspace_utils.h", + ] + + public_deps = [ + "//base", + "//chrome/browser/profiles:profile", + "//components/keyed_service/core", + "//components/pref_registry", + "//components/sessions", + ] +} + +source_set("unit_tests") { + testonly = true + sources = [ "workspace_service_unittest.cc" ] + deps = [ + ":impl", + ":workspaces", + "//base", + "//base/test:test_support", + "//chrome/test:test_support", + "//components/sessions", + "//components/sync_preferences:test_support", + "//content/test:test_support", + "//testing/gtest", + ] +} diff --git a/browser/workspaces/features.cc b/browser/workspaces/features.cc new file mode 100644 index 00000000000..f4d3d1cbd85 --- /dev/null +++ b/browser/workspaces/features.cc @@ -0,0 +1,15 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/browser/workspaces/features.h" + +namespace features { + +// Allows saving open tabs and windows as a named workspace for later restore. +// NOTE: Being implemented in phases. +// See https://github.com/brave/brave-browser/issues/54738 +BASE_FEATURE(kWorkspaces, base::FEATURE_DISABLED_BY_DEFAULT); + +} // namespace features diff --git a/browser/workspaces/features.h b/browser/workspaces/features.h new file mode 100644 index 00000000000..f890af8d46c --- /dev/null +++ b/browser/workspaces/features.h @@ -0,0 +1,17 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_BROWSER_WORKSPACES_FEATURES_H_ +#define BRAVE_BROWSER_WORKSPACES_FEATURES_H_ + +#include "base/feature_list.h" + +namespace features { + +BASE_DECLARE_FEATURE(kWorkspaces); + +} // namespace features + +#endif // BRAVE_BROWSER_WORKSPACES_FEATURES_H_ diff --git a/browser/workspaces/pref_names.h b/browser/workspaces/pref_names.h new file mode 100644 index 00000000000..cec2b78578a --- /dev/null +++ b/browser/workspaces/pref_names.h @@ -0,0 +1,12 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_BROWSER_WORKSPACES_PREF_NAMES_H_ +#define BRAVE_BROWSER_WORKSPACES_PREF_NAMES_H_ + +// Profile preference key — stores a dict keyed by hash of the display name. +inline constexpr char kWorkspacesMetadataPref[] = "brave.workspaces.metadata"; + +#endif // BRAVE_BROWSER_WORKSPACES_PREF_NAMES_H_ diff --git a/browser/workspaces/workspace_metadata.h b/browser/workspaces/workspace_metadata.h new file mode 100644 index 00000000000..decb3b1a31d --- /dev/null +++ b/browser/workspaces/workspace_metadata.h @@ -0,0 +1,22 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_METADATA_H_ +#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_METADATA_H_ + +#include + +#include "base/time/time.h" + +// Lightweight summary returned by ListWorkspaces(), used to populate UI. +// The full session state is stored as Chromium session commands on disk. +struct WorkspaceMetadata { + std::string name; + base::Time modified_at; + int number_of_windows = 0; + int number_of_tabs = 0; +}; + +#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_METADATA_H_ diff --git a/browser/workspaces/workspace_service.cc b/browser/workspaces/workspace_service.cc new file mode 100644 index 00000000000..7a5ac0f458a --- /dev/null +++ b/browser/workspaces/workspace_service.cc @@ -0,0 +1,141 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/browser/workspaces/workspace_service.h" + +#include +#include +#include +#include + +#include "base/functional/bind.h" +#include "base/hash/hash.h" +#include "base/logging.h" +#include "base/task/bind_post_task.h" +#include "base/task/sequenced_task_runner.h" +#include "base/task/thread_pool.h" +#include "base/time/time.h" +#include "brave/browser/workspaces/workspace_session_utils.h" +#include "brave/browser/workspaces/workspace_utils.h" +#include "chrome/browser/profiles/profile.h" +#include "components/prefs/pref_service.h" +#include "components/prefs/scoped_user_pref_update.h" +#include "components/sessions/core/session_id.h" +#include "third_party/abseil-cpp/absl/strings/str_format.h" + +namespace { + +std::string ComputeKey(const std::string& name) { + return absl::StrFormat("%08x", base::PersistentHash(name)); +} + +} // namespace + +WorkspaceService::WorkspaceService(Profile& profile) + : profile_(profile), + workspaces_path_(profile.GetPath().AppendASCII("workspaces")), + pref_service_(*profile.GetPrefs()), + io_task_runner_(base::ThreadPool::CreateSequencedTaskRunner( + {base::MayBlock(), base::TaskPriority::USER_VISIBLE, + base::TaskShutdownBehavior::BLOCK_SHUTDOWN})) {} + +WorkspaceService::~WorkspaceService() = default; + +std::vector WorkspaceService::ListWorkspaces() const { + return ListWorkspacesFromDict( + pref_service_->GetDict(kWorkspacesMetadataPref)); +} + +void WorkspaceService::SaveWorkspaceMetadata(const WorkspaceMetadata& meta) { + ScopedDictPrefUpdate updated(*pref_service_, kWorkspacesMetadataPref); + updated->Set(ComputeKey(meta.name), WorkspaceMetadataToDictEntry(meta)); +} + +void WorkspaceService::RemoveWorkspaceMetadata(const std::string& name) { + ScopedDictPrefUpdate updated(*pref_service_, kWorkspacesMetadataPref); + updated->Remove(ComputeKey(name)); +} + +base::FilePath WorkspaceService::GetWorkspacePathForName( + const std::string& name) const { + return workspaces_path_.AppendASCII(ComputeKey(name)); +} + +void WorkspaceService::SaveWorkspace(const std::string& name) { + if (name.empty()) { + return; + } + + // Collect session commands on the UI thread, then write to disk on a + // background task (WriteWorkspaceToDisk does blocking file I/O). + WorkspaceMetadata workspace{.name = name, .modified_at = base::Time::Now()}; + std::vector> commands = + GenerateBrowserSessionCommandsForWorkspace(base::to_address(profile_), + workspace); + base::FilePath workspace_path = GetWorkspacePathForName(name); + + auto backend = base::MakeRefCounted( + io_task_runner_, workspace_path, kWorkspaceSessionType, + /*encryptor=*/std::nullopt); + + // Save metadata optimistically now; roll it back if the write fails. + // BindPostTask ensures on_error always runs on the UI thread whether it is + // invoked by AppendCommands or directly by WriteWorkspaceToDisk on a + // directory-creation failure. + SaveWorkspaceMetadata(workspace); + + // "Rolling back" is just removing the metadata from the dictionary. + auto on_error = base::BindPostTask( + base::SequencedTaskRunner::GetCurrentDefault(), + base::BindOnce(&WorkspaceService::RemoveWorkspaceMetadata, GetWeakPtr(), + name)); + + io_task_runner_->PostTask( + FROM_HERE, base::BindOnce(&WriteWorkspaceToDisk, std::move(commands), + std::move(workspace_path), std::move(backend), + std::move(on_error))); +} + +void WorkspaceService::RestoreWorkspace(const std::string& name) { + base::FilePath path = GetWorkspacePathForName(name); + auto backend = base::MakeRefCounted( + io_task_runner_, path, kWorkspaceSessionType, + /*encryptor=*/std::nullopt); + + io_task_runner_->PostTaskAndReplyWithResult( + FROM_HERE, + base::BindOnce(&ReadWorkspaceFromDisk, std::move(path), + std::move(backend)), + base::BindOnce(&WorkspaceService::DoRestoreWorkspace, GetWeakPtr())); +} + +void WorkspaceService::ShowSaveWorkspaceDialog() { + // TODO(https://github.com/brave/brave-browser/issues/55108) + SaveWorkspace("example-workspace"); +} + +void WorkspaceService::ShowOpenWorkspaceDialog() { + // TODO(https://github.com/brave/brave-browser/issues/55108) + RestoreWorkspace("example-workspace"); +} + +void WorkspaceService::DoRestoreWorkspace( + std::vector> commands) { + if (commands.empty()) { + DVLOG(1) << "Could not load workspace: no commands"; + return; + } + + RestoreBrowserSessionCommandsForWorkspace(base::to_address(profile_), + std::move(commands)); +} + +base::WeakPtr WorkspaceService::GetWeakPtr() { + return weak_ptr_factory_.GetWeakPtr(); +} + +void WorkspaceService::Shutdown() { + weak_ptr_factory_.InvalidateWeakPtrs(); +} diff --git a/browser/workspaces/workspace_service.h b/browser/workspaces/workspace_service.h new file mode 100644 index 00000000000..bd863f4a311 --- /dev/null +++ b/browser/workspaces/workspace_service.h @@ -0,0 +1,100 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_H_ +#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_H_ + +#include +#include +#include + +#include "base/files/file_path.h" +#include "base/memory/raw_ref.h" +#include "base/memory/scoped_refptr.h" +#include "base/memory/weak_ptr.h" +#include "base/task/sequenced_task_runner.h" +#include "brave/browser/workspaces/pref_names.h" +#include "brave/browser/workspaces/workspace_metadata.h" +#include "components/keyed_service/core/keyed_service.h" +#include "components/sessions/core/session_command.h" + +class PrefService; +class Profile; + +// Per-profile service that manages saving and restoring named workspaces. +// +// Each workspace is stored in its own subdirectory under +// {profile_dir}/workspaces/{hashed_name}/ +// +// Inside that subdirectory: +// Sessions/Session_* — full browser state in Chromium's session-command +// binary format (handled by CommandStorageBackend) +// +// Workspace metadata (display name, window/tab counts, creation time) is +// stored in a profile preference rather than a per-directory info.json file. +// +// Threading note: All workspace file I/O runs on |io_task_runner_|, a +// MayBlock SequencedTaskRunner created at construction time. Backends are +// constructed on the UI thread with |io_task_runner_| as their owning +// sequence, then all blocking I/O tasks are posted to that same runner. +class WorkspaceService : public KeyedService { + public: + explicit WorkspaceService(Profile& profile); + ~WorkspaceService() override; + + WorkspaceService(const WorkspaceService&) = delete; + WorkspaceService& operator=(const WorkspaceService&) = delete; + + // Returns summary information for all saved workspaces, sorted by time + // modified (most-recent first). Reads from the profile preference; no disk + // I/O. + std::vector ListWorkspaces() const; + + // Writes workspace metadata into the profile preference. Called on the UI + // thread after a successful WriteWorkspaceToDisk background task. + void SaveWorkspaceMetadata(const WorkspaceMetadata& meta); + + // Removes the workspace metadata entry from the profile preference. + void RemoveWorkspaceMetadata(const std::string& name); + + // Returns the directory that contains all workspace subdirectories. + const base::FilePath& GetWorkspacesPath() const { return workspaces_path_; } + + // Returns the per-workspace subdirectory for |name|. + base::FilePath GetWorkspacePathForName(const std::string& name) const; + + // ---- Browser-state commands (UI thread only) ---------------------------- + + // Serializes all open windows/tabs for this profile and writes them to disk + // under the given workspace name. + void SaveWorkspace(const std::string& name); + + // Reads the named workspace from disk and opens its windows/tabs. + void RestoreWorkspace(const std::string& name); + + // Placeholder entry points for the save/open dialogs. + void ShowSaveWorkspaceDialog(); + void ShowOpenWorkspaceDialog(); + + base::WeakPtr GetWeakPtr(); + + // KeyedService: + void Shutdown() override; + + private: + // Called on the UI thread with the commands read from disk by + // RestoreWorkspace. + void DoRestoreWorkspace( + std::vector> commands); + + raw_ref profile_; + const base::FilePath workspaces_path_; + raw_ref pref_service_; + scoped_refptr io_task_runner_; + + base::WeakPtrFactory weak_ptr_factory_{this}; +}; + +#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_H_ diff --git a/browser/workspaces/workspace_service_factory.cc b/browser/workspaces/workspace_service_factory.cc new file mode 100644 index 00000000000..0a2f3d8b9ad --- /dev/null +++ b/browser/workspaces/workspace_service_factory.cc @@ -0,0 +1,53 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/browser/workspaces/workspace_service_factory.h" + +#include + +#include "brave/browser/workspaces/features.h" +#include "brave/browser/workspaces/pref_names.h" +#include "brave/browser/workspaces/workspace_service.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/browser/profiles/profile_selections.h" +#include "components/pref_registry/pref_registry_syncable.h" + +// static +WorkspaceServiceFactory* WorkspaceServiceFactory::GetInstance() { + static base::NoDestructor instance; + return instance.get(); +} + +// static +WorkspaceService* WorkspaceServiceFactory::GetForProfile(Profile* profile) { + return static_cast( + GetInstance()->GetServiceForBrowserContext(profile, /*create=*/true)); +} + +WorkspaceServiceFactory::WorkspaceServiceFactory() + : ProfileKeyedServiceFactory("WorkspaceService", + ProfileSelections::BuildForRegularProfile()) {} + +WorkspaceServiceFactory::~WorkspaceServiceFactory() = default; + +std::unique_ptr +WorkspaceServiceFactory::BuildServiceInstanceForBrowserContext( + content::BrowserContext* context) const { + if (!base::FeatureList::IsEnabled(features::kWorkspaces)) { + return nullptr; + } + + Profile* profile = Profile::FromBrowserContext(context); + return std::make_unique(*profile); +} + +void WorkspaceServiceFactory::RegisterProfilePrefs( + user_prefs::PrefRegistrySyncable* registry) { + registry->RegisterDictionaryPref(kWorkspacesMetadataPref); +} + +bool WorkspaceServiceFactory::ServiceIsCreatedWithBrowserContext() const { + return true; +} diff --git a/browser/workspaces/workspace_service_factory.h b/browser/workspaces/workspace_service_factory.h new file mode 100644 index 00000000000..46f1796cf1a --- /dev/null +++ b/browser/workspaces/workspace_service_factory.h @@ -0,0 +1,41 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_FACTORY_H_ +#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_FACTORY_H_ + +#include "base/no_destructor.h" +#include "chrome/browser/profiles/profile_keyed_service_factory.h" + +namespace user_prefs { +class PrefRegistrySyncable; +} // namespace user_prefs + +class WorkspaceService; +class Profile; + +class WorkspaceServiceFactory : public ProfileKeyedServiceFactory { + public: + static WorkspaceServiceFactory* GetInstance(); + static WorkspaceService* GetForProfile(Profile* profile); + + WorkspaceServiceFactory(const WorkspaceServiceFactory&) = delete; + WorkspaceServiceFactory& operator=(const WorkspaceServiceFactory&) = delete; + + private: + friend class base::NoDestructor; + + WorkspaceServiceFactory(); + ~WorkspaceServiceFactory() override; + + // ProfileKeyedServiceFactory: + std::unique_ptr BuildServiceInstanceForBrowserContext( + content::BrowserContext* context) const override; + void RegisterProfilePrefs( + user_prefs::PrefRegistrySyncable* registry) override; + bool ServiceIsCreatedWithBrowserContext() const override; +}; + +#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_FACTORY_H_ diff --git a/browser/workspaces/workspace_service_unittest.cc b/browser/workspaces/workspace_service_unittest.cc new file mode 100644 index 00000000000..3acc8c48697 --- /dev/null +++ b/browser/workspaces/workspace_service_unittest.cc @@ -0,0 +1,275 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/browser/workspaces/workspace_service.h" + +#include +#include + +#include "base/files/file_util.h" +#include "base/functional/bind.h" +#include "base/memory/raw_ptr.h" +#include "base/task/bind_post_task.h" +#include "base/task/sequenced_task_runner.h" +#include "base/task/thread_pool.h" +#include "base/test/bind.h" +#include "base/test/run_until.h" +#include "base/test/scoped_feature_list.h" +#include "base/test/test_future.h" +#include "base/time/time.h" +#include "brave/browser/workspaces/features.h" +#include "brave/browser/workspaces/workspace_metadata.h" +#include "brave/browser/workspaces/workspace_service_factory.h" +#include "brave/browser/workspaces/workspace_utils.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/test/base/testing_browser_process.h" +#include "chrome/test/base/testing_profile_manager.h" +#include "components/sessions/core/command_storage_backend.h" +#include "components/sessions/core/session_id.h" +#include "components/sessions/core/session_service_commands.h" +#include "components/sessions/core/session_types.h" +#include "content/public/test/browser_task_environment.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { +WorkspaceMetadata MakeMeta(const std::string& name, + int windows, + int tabs, + base::Time t) { + WorkspaceMetadata meta{.name = name, + .modified_at = t, + .number_of_windows = windows, + .number_of_tabs = tabs}; + return meta; +} +} // namespace + +class WorkspaceServiceTest : public ::testing::Test { + protected: + void SetUp() override { + ASSERT_TRUE(profile_manager_.SetUp()); + // Instantiate the factory so it registers with + // BrowserContextDependencyManager before profile creation, ensuring + // kWorkspacesMetadataPref is registered. + WorkspaceServiceFactory::GetInstance(); + profile_ = profile_manager_.CreateTestingProfile("test"); + service_ = std::make_unique(*profile_); + } + + content::BrowserTaskEnvironment task_environment_; + TestingProfileManager profile_manager_{TestingBrowserProcess::GetGlobal()}; + raw_ptr profile_ = nullptr; + std::unique_ptr service_; +}; + +// ---- Metadata (pref) tests -------------------------------------------------- + +// No workspaces listed w/ empty profile +TEST_F(WorkspaceServiceTest, ListWorkspaces_InitiallyEmpty) { + EXPECT_TRUE(service_->ListWorkspaces().empty()); +} + +// Verify saving preference adds workspace to list +TEST_F(WorkspaceServiceTest, SaveMetadata_AppearsInList) { + base::Time t = base::Time::FromSecondsSinceUnixEpoch(1700000000.0); + service_->SaveWorkspaceMetadata(MakeMeta("My Workspace", 2, 5, t)); + + auto workspaces = service_->ListWorkspaces(); + ASSERT_EQ(workspaces.size(), 1u); + EXPECT_EQ(workspaces[0].name, "My Workspace"); + EXPECT_EQ(workspaces[0].number_of_windows, 2); + EXPECT_EQ(workspaces[0].number_of_tabs, 5); + EXPECT_EQ(workspaces[0].modified_at, + base::Time::FromSecondsSinceUnixEpoch(1700000000.0)); +} + +// Verify listing of workspaces orders by date modified desc +TEST_F(WorkspaceServiceTest, ListWorkspaces_SortedByModifiedAtDescending) { + base::Time t1 = base::Time::FromSecondsSinceUnixEpoch(1700000000.0); + base::Time t2 = base::Time::FromSecondsSinceUnixEpoch(1700000100.0); + base::Time t3 = base::Time::FromSecondsSinceUnixEpoch(1699999900.0); + service_->SaveWorkspaceMetadata(MakeMeta("Alpha", 1, 3, t1)); + service_->SaveWorkspaceMetadata(MakeMeta("Beta", 1, 5, t2)); + service_->SaveWorkspaceMetadata(MakeMeta("Gamma", 1, 2, t3)); + + auto workspaces = service_->ListWorkspaces(); + ASSERT_EQ(workspaces.size(), 3u); + EXPECT_EQ(workspaces[0].name, "Beta"); + EXPECT_EQ(workspaces[1].name, "Alpha"); + EXPECT_EQ(workspaces[2].name, "Gamma"); +} + +// Removing preference removes it from the list +TEST_F(WorkspaceServiceTest, RemoveMetadata_DisappearsFromList) { + base::Time t = base::Time::FromSecondsSinceUnixEpoch(1700000000.0); + service_->SaveWorkspaceMetadata(MakeMeta("Work", 1, 3, t)); + ASSERT_EQ(service_->ListWorkspaces().size(), 1u); + + service_->RemoveWorkspaceMetadata("Work"); + EXPECT_TRUE(service_->ListWorkspaces().empty()); +} + +// Saving a workspace over another one replaces it +TEST_F(WorkspaceServiceTest, SaveMetadata_SameNameOverwrites) { + base::Time t1 = base::Time::FromSecondsSinceUnixEpoch(1700000000.0); + base::Time t2 = base::Time::FromSecondsSinceUnixEpoch(1700000100.0); + service_->SaveWorkspaceMetadata(MakeMeta("Work", 1, 3, t1)); + service_->SaveWorkspaceMetadata(MakeMeta("Work", 2, 7, t2)); + + auto workspaces = service_->ListWorkspaces(); + ASSERT_EQ(workspaces.size(), 1u); + EXPECT_EQ(workspaces[0].name, "Work"); + EXPECT_EQ(workspaces[0].number_of_windows, 2); + EXPECT_EQ(workspaces[0].number_of_tabs, 7); +} + +// ---- Name / path computation tests ----------------------------------------- + +// Key is a stable hex hash — different display names get different paths. +TEST_F(WorkspaceServiceTest, + GetWorkspacePathForName_DifferentNamesDifferentPaths) { + base::FilePath path1 = service_->GetWorkspacePathForName("Work Space"); + base::FilePath path2 = service_->GetWorkspacePathForName("Work-Space"); + EXPECT_NE(path1, path2); +} + +// Same name always resolves to the same path (deterministic key). +TEST_F(WorkspaceServiceTest, GetWorkspacePathForName_SameNameSamePath) { + EXPECT_EQ(service_->GetWorkspacePathForName("My Workspace"), + service_->GetWorkspacePathForName("My Workspace")); +} + +// ---- Disk I/O tests (async, use RunUntil not RunUntilIdle) ------------------ + +// Verify that WriteWorkspaceToDisk saves the profile without calling onError. +TEST_F(WorkspaceServiceTest, WriteAndReadRoundTrip) { + using CommandList = std::vector>; + + SessionID window_id = SessionID::NewUnique(); + CommandList commands; + commands.push_back(sessions::CreateSetWindowTypeCommand( + window_id, sessions::SessionWindow::TYPE_NORMAL)); + commands.push_back( + sessions::CreateSetSelectedTabInWindowCommand(window_id, 0)); + const size_t num_commands = commands.size(); + + base::FilePath workspace_path = + service_->GetWorkspacePathForName("roundtrip"); + auto task_runner = base::ThreadPool::CreateSequencedTaskRunner( + {base::MayBlock(), base::TaskPriority::USER_VISIBLE, + base::TaskShutdownBehavior::BLOCK_SHUTDOWN}); + auto backend = base::MakeRefCounted( + task_runner, workspace_path, kWorkspaceSessionType, + /*encryptor=*/std::nullopt); + + bool error_called = false; + bool write_done = false; + auto on_error = base::BindPostTask( + base::SequencedTaskRunner::GetCurrentDefault(), + base::BindOnce([](bool* flag) { *flag = true; }, &error_called)); + + task_runner->PostTask( + FROM_HERE, + base::BindOnce(&WriteWorkspaceToDisk, std::move(commands), workspace_path, + std::move(backend), std::move(on_error))); + // A sequenced task after the write runs only after it completes. + task_runner->PostTask( + FROM_HERE, + base::BindPostTask( + base::SequencedTaskRunner::GetCurrentDefault(), + base::BindOnce([](bool* flag) { *flag = true; }, &write_done))); + + ASSERT_TRUE(base::test::RunUntil([&] { return write_done; })); + ASSERT_FALSE(error_called); + + // Read back using a fresh backend (mirrors RestoreWorkspace's pattern). + auto read_backend = base::MakeRefCounted( + task_runner, workspace_path, kWorkspaceSessionType, + /*encryptor=*/std::nullopt); + base::test::TestFuture read_future; + task_runner->PostTaskAndReplyWithResult( + FROM_HERE, + base::BindOnce(&ReadWorkspaceFromDisk, workspace_path, read_backend), + read_future.GetCallback()); + + ASSERT_TRUE(read_future.Wait()); + const auto& read_cmds = read_future.Get(); + ASSERT_EQ(read_cmds.size(), num_commands); + + // Verify content — not just count — survived the round-trip. + // Re-creating the reference commands from the same inputs and comparing + // serialized bytes confirms the window ID and TYPE_NORMAL encoding are + // preserved end-to-end (both are embedded in the serialized payload). + EXPECT_EQ(read_cmds[0]->Serialize(), + sessions::CreateSetWindowTypeCommand( + window_id, sessions::SessionWindow::TYPE_NORMAL) + ->Serialize()); + EXPECT_EQ( + read_cmds[1]->Serialize(), + sessions::CreateSetSelectedTabInWindowCommand(window_id, 0)->Serialize()); +} + +TEST_F(WorkspaceServiceTest, + WriteToDisk_DirectoryCreationFailure_CallsOnError) { + // Place a regular FILE where the workspace directory would be so that + // base::CreateDirectory fails on the background thread and on_error is + // posted back to the UI thread. + ASSERT_TRUE(base::CreateDirectory(service_->GetWorkspacesPath())); + base::FilePath workspace_path = service_->GetWorkspacePathForName("blocked"); + ASSERT_TRUE(base::WriteFile(workspace_path, "not-a-directory")); + + auto task_runner = base::ThreadPool::CreateSequencedTaskRunner( + {base::MayBlock(), base::TaskPriority::USER_VISIBLE, + base::TaskShutdownBehavior::BLOCK_SHUTDOWN}); + auto backend = base::MakeRefCounted( + task_runner, workspace_path, kWorkspaceSessionType, + /*encryptor=*/std::nullopt); + + bool error_called = false; + auto on_error = base::BindPostTask( + base::SequencedTaskRunner::GetCurrentDefault(), + base::BindOnce([](bool* flag) { *flag = true; }, &error_called)); + + task_runner->PostTask( + FROM_HERE, + base::BindOnce(&WriteWorkspaceToDisk, + std::vector>(), + workspace_path, std::move(backend), std::move(on_error))); + + EXPECT_TRUE(base::test::RunUntil([&] { return error_called; })); +} + +// ---- Factory tests ---------------------------------------------------------- + +class WorkspaceServiceFactoryTest : public ::testing::Test { + protected: + void SetUp() override { ASSERT_TRUE(profile_manager_.SetUp()); } + + content::BrowserTaskEnvironment task_environment_; + base::test::ScopedFeatureList feature_list_; + TestingProfileManager profile_manager_{TestingBrowserProcess::GetGlobal()}; +}; + +TEST_F(WorkspaceServiceFactoryTest, FeatureDisabled_GetForProfileReturnsNull) { + feature_list_.InitAndDisableFeature(features::kWorkspaces); + auto* profile = profile_manager_.CreateTestingProfile("test"); + EXPECT_EQ(WorkspaceServiceFactory::GetForProfile(profile), nullptr); +} + +TEST_F(WorkspaceServiceFactoryTest, + FeatureEnabled_GetForProfileReturnsNonNull) { + feature_list_.InitAndEnableFeature(features::kWorkspaces); + auto* profile = profile_manager_.CreateTestingProfile("test"); + // ServiceIsNULLWhileTesting() suppresses service creation for test profiles. + // Provide a testing factory so the feature-enabled path is exercised. + WorkspaceServiceFactory::GetInstance()->SetTestingFactory( + profile, + base::BindLambdaForTesting( + [](content::BrowserContext* ctx) -> std::unique_ptr { + auto* p = Profile::FromBrowserContext(ctx); + return std::make_unique(*p); + })); + EXPECT_NE(WorkspaceServiceFactory::GetForProfile(profile), nullptr); +} diff --git a/browser/workspaces/workspace_session_utils.cc b/browser/workspaces/workspace_session_utils.cc new file mode 100644 index 00000000000..4d94d690617 --- /dev/null +++ b/browser/workspaces/workspace_session_utils.cc @@ -0,0 +1,231 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/browser/workspaces/workspace_session_utils.h" + +#include +#include +#include +#include + +#include "brave/browser/workspaces/workspace_metadata.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_tabrestore.h" +#include "chrome/browser/ui/browser_window.h" +#include "chrome/browser/ui/browser_window/public/browser_window_interface.h" +#include "chrome/browser/ui/browser_window/public/global_browser_collection.h" +#include "chrome/browser/ui/tabs/tab_group_model.h" +#include "chrome/browser/ui/tabs/tab_strip_model.h" +#include "components/sessions/content/content_serialized_navigation_builder.h" +#include "components/sessions/core/session_service_commands.h" +#include "components/sessions/core/session_types.h" +#include "components/tab_groups/tab_group_visual_data.h" +#include "components/tabs/public/tab_group.h" +#include "content/public/browser/navigation_controller.h" +#include "content/public/browser/navigation_entry.h" +#include "content/public/browser/web_contents.h" + +namespace { + +// Appends session commands for a single browser window to |commands|. +// Serializes window type, bounds, tab groups, tabs (with full navigation +// history), pinned state, and the active tab index. +void AppendBrowserSessionCommands( + const SessionID& window_id, + TabStripModel* tsm, + gfx::Rect restored_bounds, + ui::mojom::WindowShowState restored_state, + std::vector>& commands) { + commands.push_back(sessions::CreateSetWindowTypeCommand( + window_id, sessions::SessionWindow::TYPE_NORMAL)); + commands.push_back(sessions::CreateSetWindowBoundsCommand( + window_id, restored_bounds, restored_state)); + + // Emit group metadata for every tab group in this window. + if (tsm->group_model()) { + for (const tab_groups::TabGroupId& group_id : + tsm->group_model()->ListTabGroups()) { + auto* group = tsm->group_model()->GetTabGroup(group_id); + if (!group || !group->visual_data()) { + continue; + } + commands.push_back(sessions::CreateTabGroupMetadataUpdateCommand( + group_id, group->visual_data())); + } + } + + for (int i = 0; i < tsm->count(); ++i) { + content::WebContents* contents = tsm->GetWebContentsAt(i); + CHECK(contents); + + SessionID tab_id = SessionID::NewUnique(); + + commands.push_back(sessions::CreateSetTabWindowCommand(window_id, tab_id)); + commands.push_back(sessions::CreateSetTabIndexInWindowCommand(tab_id, i)); + + if (tsm->IsTabPinned(i)) { + commands.push_back(sessions::CreatePinnedStateCommand(tab_id, true)); + } + + std::optional group_id = tsm->GetTabGroupForTab(i); + if (group_id.has_value()) { + commands.push_back(sessions::CreateTabGroupCommand(tab_id, group_id)); + } + + // Serialize the full navigation history for this tab. + auto& controller = contents->GetController(); + int nav_count = controller.GetEntryCount(); + int current_entry = controller.GetCurrentEntryIndex(); + for (int j = 0; j < nav_count; ++j) { + content::NavigationEntry* entry = controller.GetEntryAtIndex(j); + if (!entry) { + continue; + } + auto serialized = + sessions::ContentSerializedNavigationBuilder::FromNavigationEntry( + j, entry); + commands.push_back( + sessions::CreateUpdateTabNavigationCommand(tab_id, serialized)); + } + + commands.push_back(sessions::CreateSetSelectedNavigationIndexCommand( + tab_id, current_entry)); + } + + commands.push_back(sessions::CreateSetSelectedTabInWindowCommand( + window_id, tsm->active_index())); +} + +} // namespace + +std::vector> +GenerateBrowserSessionCommandsForWorkspace(Profile* profile, + WorkspaceMetadata& workspace) { + std::vector> commands; + SessionID active_window_id = SessionID::InvalidValue(); + + GlobalBrowserCollection::GetInstance()->ForEach( + [&](BrowserWindowInterface* bwi) { + if (bwi->GetProfile() != profile || + bwi->GetType() != BrowserWindowInterface::Type::TYPE_NORMAL) { + return true; + } + + int tabs = bwi->GetTabStripModel()->count(); + if (tabs == 0) { + return true; + } + + SessionID window_id = SessionID::NewUnique(); + auto* window = bwi->GetWindow(); + AppendBrowserSessionCommands(window_id, bwi->GetTabStripModel(), + window->GetRestoredBounds(), + window->GetRestoredState(), commands); + + // Prefer the focused window; fall back to the first non-empty window. + if (window->IsActive() || + active_window_id == SessionID::InvalidValue()) { + active_window_id = window_id; + } + workspace.number_of_windows++; + workspace.number_of_tabs += tabs; + return true; + }); + + if (workspace.number_of_tabs == 0) { + return {}; + } + + if (active_window_id != SessionID::InvalidValue()) { + commands.push_back( + sessions::CreateSetActiveWindowCommand(active_window_id)); + } + + return commands; +} + +void RestoreBrowserSessionCommandsForWorkspace( + Profile* profile, + std::vector> commands) { + // RestoreSessionFromCommands constructs SessionTab/SessionWindow objects + // whose constructors call SessionID::NewUnique(), which is sequence-checked + // to the UI thread. It must therefore be called here (UI thread), not in + // the background I/O task. + std::vector> windows; + SessionID active_window_id = SessionID::InvalidValue(); + std::string platform_session_id; + std::set discarded_window_ids; + sessions::RestoreSessionFromCommands(commands, &windows, &active_window_id, + &platform_session_id, + &discarded_window_ids); + if (windows.empty()) { + return; + } + + // We restore tabs ourselves rather than using RestoreForeignSessionWindows + // because that API creates an empty new_group_ids map and never calls + // RestoreTabGroupMetadata, so tab group names/colors are silently dropped. + // By passing the original TabGroupId directly to AddRestoredTab, we avoid + // any ID remapping and can apply visual data with the same IDs afterwards. + for (const auto& window : windows) { + if (window->tabs.empty()) { + continue; + } + + if (Browser::GetCreationStatusForProfile(profile) != + Browser::CreationStatus::kOk) { + continue; + } + + Browser::CreateParams params(Browser::TYPE_NORMAL, profile, false); + params.initial_bounds = window->bounds; + params.initial_show_state = window->show_state; + params.initial_workspace = window->workspace; + params.initial_visible_on_all_workspaces_state = + window->visible_on_all_workspaces; + params.should_trigger_session_restore = false; + Browser* browser = Browser::Create(params); + if (!browser) { + continue; + } + + auto* tsm = browser->tab_strip_model(); + for (size_t i = 0; i < window->tabs.size(); ++i) { + const auto& tab = window->tabs[i]; + if (tab->navigations.empty()) { + continue; + } + chrome::AddRestoredTab( + browser, tab->navigations, + /*tab_index=*/i, tab->normalized_navigation_index(), + tab->extension_app_id, + /*group=*/tab->group, + /*select=*/false, tab->pinned, + /*last_active_time_ticks=*/base::TimeTicks(), tab->last_active_time, + /*storage_namespace=*/nullptr, tab->user_agent_override, + tab->extra_data, + /*from_session_restore=*/true, + /*is_active_browser=*/std::nullopt); + } + + // Apply group names, colors, and collapsed state. Since we passed the + // original TabGroupIds to AddRestoredTab, no ID remapping is needed. + for (const auto& session_group : window->tab_groups) { + if (!tsm->group_model() || + !tsm->group_model()->ContainsTabGroup(session_group->id)) { + continue; + } + tsm->ChangeTabGroupVisuals(session_group->id, session_group->visual_data); + } + + int active = std::clamp(window->selected_tab_index, 0, + std::max(0, tsm->count() - 1)); + if (active < tsm->count()) { + tsm->ActivateTabAt(active); + } + browser->window()->Show(); + } +} diff --git a/browser/workspaces/workspace_session_utils.h b/browser/workspaces/workspace_session_utils.h new file mode 100644 index 00000000000..b5aec5dd61c --- /dev/null +++ b/browser/workspaces/workspace_session_utils.h @@ -0,0 +1,35 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_SESSION_UTILS_H_ +#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_SESSION_UTILS_H_ + +#include +#include + +#include "components/sessions/core/session_command.h" +#include "components/sessions/core/session_id.h" +#include "ui/base/mojom/window_show_state.mojom.h" +#include "ui/gfx/geometry/rect.h" + +class Profile; +class TabStripModel; +struct WorkspaceMetadata; + +// Iterates all normal browser windows for |profile|, serializes their state +// into session commands, and returns the result. |window_count| and +// |tab_count| are incremented to reflect what was captured. Returns an empty +// vector if there are no tabs to save. +std::vector> +GenerateBrowserSessionCommandsForWorkspace(Profile* profile, + WorkspaceMetadata& workspace); + +// Deserializes |commands| and opens the encoded windows/tabs in new browser +// windows belonging to |profile|. Must be called on the UI thread. +void RestoreBrowserSessionCommandsForWorkspace( + Profile* profile, + std::vector> commands); + +#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_SESSION_UTILS_H_ diff --git a/browser/workspaces/workspace_utils.cc b/browser/workspaces/workspace_utils.cc new file mode 100644 index 00000000000..e93c6a125ae --- /dev/null +++ b/browser/workspaces/workspace_utils.cc @@ -0,0 +1,92 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#include "brave/browser/workspaces/workspace_utils.h" + +#include +#include +#include +#include + +#include "base/files/file_util.h" +#include "base/json/values_util.h" +#include "base/logging.h" +#include "base/time/time.h" + +namespace { + +constexpr const char kWorkspaceName[] = "name"; +constexpr const char kWorkspaceWindowCount[] = "number-of-windows"; +constexpr const char kWorkspaceTabCount[] = "number-of-tabs"; +constexpr const char kWorkspaceModifiedAt[] = "modified-at"; + +} // namespace + +std::vector ListWorkspacesFromDict( + const base::DictValue& dict) { + std::vector result; + for (const auto [key, value] : dict) { + const base::DictValue* entry = value.GetIfDict(); + if (!entry) { + continue; + } + const std::string* name = entry->FindString(kWorkspaceName); + if (!name || name->empty()) { + continue; + } + WorkspaceMetadata info = { + .name = *name, + .modified_at = base::ValueToTime(entry->Find(kWorkspaceModifiedAt)) + .value_or(base::Time::Now()), + .number_of_windows = entry->FindInt(kWorkspaceWindowCount).value_or(1), + .number_of_tabs = entry->FindInt(kWorkspaceTabCount).value_or(0)}; + result.push_back(std::move(info)); + } + + std::sort(result.begin(), result.end(), + [](const WorkspaceMetadata& a, const WorkspaceMetadata& b) { + return a.modified_at > b.modified_at; + }); + return result; +} + +base::DictValue WorkspaceMetadataToDictEntry(const WorkspaceMetadata& meta) { + return base::DictValue() + .Set(kWorkspaceName, meta.name) + .Set(kWorkspaceWindowCount, meta.number_of_windows) + .Set(kWorkspaceTabCount, meta.number_of_tabs) + .Set(kWorkspaceModifiedAt, base::TimeToValue(meta.modified_at)); +} + +void WriteWorkspaceToDisk( + std::vector> commands, + const base::FilePath& workspace_path, + scoped_refptr backend, + base::OnceClosure on_error) { + if (!base::CreateDirectory(workspace_path)) { + DVLOG(1) << "Failed to create workspace directory: " << workspace_path; + std::move(on_error).Run(); + return; + } + // AppendCommands posts |on_error| to the backend's callback_task_runner_ + // (the UI thread, set when the backend was constructed) on write failure. + backend->AppendCommands(std::move(commands), /*truncate=*/true, + std::move(on_error)); +} + +std::vector> ReadWorkspaceFromDisk( + const base::FilePath& workspace_path, + scoped_refptr backend) { + // Only do file I/O here. Callers must call RestoreSessionFromCommands() on + // the UI thread because SessionTab/SessionWindow constructors call + // SessionID::NewUnique() which is sequence-checked to the UI thread. + sessions::CommandStorageBackend::ReadCommandsResult result = + backend->ReadLastSessionCommands(); + if (result.error_reading || result.commands.empty()) { + DVLOG(1) << "Could not read workspace session from: " << workspace_path; + return {}; + } + return std::move(result.commands); +} diff --git a/browser/workspaces/workspace_utils.h b/browser/workspaces/workspace_utils.h new file mode 100644 index 00000000000..7f05917480c --- /dev/null +++ b/browser/workspaces/workspace_utils.h @@ -0,0 +1,60 @@ +/* Copyright (c) 2026 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_UTILS_H_ +#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_UTILS_H_ + +#include +#include + +#include "base/files/file_path.h" +#include "base/functional/callback.h" +#include "base/memory/scoped_refptr.h" +#include "base/values.h" +#include "brave/browser/workspaces/workspace_metadata.h" +#include "components/sessions/core/command_storage_backend.h" +#include "components/sessions/core/command_storage_manager.h" +#include "components/sessions/core/session_command.h" + +// Session type used for all workspace files. Exposed so callers can +// construct a CommandStorageBackend on the UI thread before posting I/O +// work to a background sequenced runner. +inline constexpr sessions::CommandStorageManager::SessionType + kWorkspaceSessionType = + sessions::CommandStorageManager::SessionType::kSessionRestore; + +// Writes |commands| to a Chromium session-command binary file via |backend|. +// |on_error| is called directly if the workspace directory cannot be created, +// or is posted to the UI thread (via AppendCommands' callback_task_runner_) on +// write failure. Wrap with BindPostTask in the caller so it always lands on +// the UI thread regardless of which path fires. +// +// Must run on a background sequenced task runner. +void WriteWorkspaceToDisk( + std::vector> commands, + const base::FilePath& workspace_path, + scoped_refptr backend, + base::OnceClosure on_error); + +// Reads the session-command binary via |backend| and returns the raw commands. +// Only does file I/O — callers must deserialize the commands into +// SessionWindow objects on the UI thread (SessionID::NewUnique() is +// sequence-checked to the UI thread). Returns an empty vector on error. +// +// Must run on a background sequenced task runner. +std::vector> ReadWorkspaceFromDisk( + const base::FilePath& workspace_path, + scoped_refptr backend); + +// Parses a workspace metadata dict (the value stored under +// kWorkspacesMetadataPref) and returns a list sorted by modified_at descending. +std::vector ListWorkspacesFromDict( + const base::DictValue& dict); + +// Serializes |meta| into a single dict entry suitable for storing under its key +// in kWorkspacesMetadataPref. +base::DictValue WorkspaceMetadataToDictEntry(const WorkspaceMetadata& meta); + +#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_UTILS_H_ diff --git a/test/BUILD.gn b/test/BUILD.gn index 87f3714c29c..36f5ebb9587 100644 --- a/test/BUILD.gn +++ b/test/BUILD.gn @@ -509,6 +509,7 @@ test("brave_unit_tests") { "//brave/browser/ui/webui/settings:unit_tests", "//brave/browser/ui/webui/side_panel/customize_chrome:unit_tests", "//brave/browser/ui/whats_new:unit_test", + "//brave/browser/workspaces:unit_tests", "//brave/components/ai_chat/core/common/buildflags", "//brave/components/brave_shields/core/common:mojom", "//brave/components/brave_vpn/common/buildflags",