Workspaces - phase 1 (#36059)
* Introduce a workspace service for serializing open windows/tabs. --------- Co-authored-by: Brian Johnson <34129+bridiver@users.noreply.github.com>
This commit is contained in:
co-authored by
Brian Johnson
parent
d44a3bff68
commit
b349cfc012
@@ -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_
|
||||
|
||||
@@ -253,6 +253,24 @@
|
||||
Email Aliases
|
||||
</message>
|
||||
|
||||
<!-- Workspaces -->
|
||||
<if expr="use_titlecase">
|
||||
<message name="IDS_SAVE_WORKSPACE" desc="App menu item to save all open tabs and tab groups as a named workspace">
|
||||
Save Tabs as Workspace…
|
||||
</message>
|
||||
<message name="IDS_OPEN_WORKSPACE" desc="App menu item to open a previously saved workspace">
|
||||
Open Workspace…
|
||||
</message>
|
||||
</if>
|
||||
<if expr="not use_titlecase">
|
||||
<message name="IDS_SAVE_WORKSPACE" desc="App menu item to save all open tabs and tab groups as a named workspace">
|
||||
Save tabs as workspace…
|
||||
</message>
|
||||
<message name="IDS_OPEN_WORKSPACE" desc="App menu item to open a previously saved workspace">
|
||||
Open workspace…
|
||||
</message>
|
||||
</if>
|
||||
|
||||
<!-- Widevine -->
|
||||
<message name="IDS_WIDEVINE_PERMISSION_REQUEST_TEXT_FRAGMENT" desc="Text fragment for Widevine permission request. 'Widevine' is the name of a plugin and should not be translated.">
|
||||
Install and run Google Widevine
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -104,6 +104,7 @@ class BraveBrowserCommandController : public chrome::BrowserCommandController
|
||||
void UpdateCommandsForPin();
|
||||
void UpdateCommandForFocusMode();
|
||||
void UpdateCommandForSplitView();
|
||||
void UpdateCommandForWorkspace();
|
||||
|
||||
bool ExecuteBraveCommandWithDisposition(int id,
|
||||
WindowOpenDisposition disposition,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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 <string>
|
||||
|
||||
#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_
|
||||
@@ -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 <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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<WorkspaceMetadata> 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<std::unique_ptr<sessions::SessionCommand>> commands =
|
||||
GenerateBrowserSessionCommandsForWorkspace(base::to_address(profile_),
|
||||
workspace);
|
||||
base::FilePath workspace_path = GetWorkspacePathForName(name);
|
||||
|
||||
auto backend = base::MakeRefCounted<sessions::CommandStorageBackend>(
|
||||
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<sessions::CommandStorageBackend>(
|
||||
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<std::unique_ptr<sessions::SessionCommand>> commands) {
|
||||
if (commands.empty()) {
|
||||
DVLOG(1) << "Could not load workspace: no commands";
|
||||
return;
|
||||
}
|
||||
|
||||
RestoreBrowserSessionCommandsForWorkspace(base::to_address(profile_),
|
||||
std::move(commands));
|
||||
}
|
||||
|
||||
base::WeakPtr<WorkspaceService> WorkspaceService::GetWeakPtr() {
|
||||
return weak_ptr_factory_.GetWeakPtr();
|
||||
}
|
||||
|
||||
void WorkspaceService::Shutdown() {
|
||||
weak_ptr_factory_.InvalidateWeakPtrs();
|
||||
}
|
||||
@@ -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 <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<WorkspaceMetadata> 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<WorkspaceService> GetWeakPtr();
|
||||
|
||||
// KeyedService:
|
||||
void Shutdown() override;
|
||||
|
||||
private:
|
||||
// Called on the UI thread with the commands read from disk by
|
||||
// RestoreWorkspace.
|
||||
void DoRestoreWorkspace(
|
||||
std::vector<std::unique_ptr<sessions::SessionCommand>> commands);
|
||||
|
||||
raw_ref<Profile> profile_;
|
||||
const base::FilePath workspaces_path_;
|
||||
raw_ref<PrefService> pref_service_;
|
||||
scoped_refptr<base::SequencedTaskRunner> io_task_runner_;
|
||||
|
||||
base::WeakPtrFactory<WorkspaceService> weak_ptr_factory_{this};
|
||||
};
|
||||
|
||||
#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_H_
|
||||
@@ -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 <memory>
|
||||
|
||||
#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<WorkspaceServiceFactory> instance;
|
||||
return instance.get();
|
||||
}
|
||||
|
||||
// static
|
||||
WorkspaceService* WorkspaceServiceFactory::GetForProfile(Profile* profile) {
|
||||
return static_cast<WorkspaceService*>(
|
||||
GetInstance()->GetServiceForBrowserContext(profile, /*create=*/true));
|
||||
}
|
||||
|
||||
WorkspaceServiceFactory::WorkspaceServiceFactory()
|
||||
: ProfileKeyedServiceFactory("WorkspaceService",
|
||||
ProfileSelections::BuildForRegularProfile()) {}
|
||||
|
||||
WorkspaceServiceFactory::~WorkspaceServiceFactory() = default;
|
||||
|
||||
std::unique_ptr<KeyedService>
|
||||
WorkspaceServiceFactory::BuildServiceInstanceForBrowserContext(
|
||||
content::BrowserContext* context) const {
|
||||
if (!base::FeatureList::IsEnabled(features::kWorkspaces)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Profile* profile = Profile::FromBrowserContext(context);
|
||||
return std::make_unique<WorkspaceService>(*profile);
|
||||
}
|
||||
|
||||
void WorkspaceServiceFactory::RegisterProfilePrefs(
|
||||
user_prefs::PrefRegistrySyncable* registry) {
|
||||
registry->RegisterDictionaryPref(kWorkspacesMetadataPref);
|
||||
}
|
||||
|
||||
bool WorkspaceServiceFactory::ServiceIsCreatedWithBrowserContext() const {
|
||||
return true;
|
||||
}
|
||||
@@ -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();
|
||||
~WorkspaceServiceFactory() override;
|
||||
|
||||
// ProfileKeyedServiceFactory:
|
||||
std::unique_ptr<KeyedService> 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_
|
||||
@@ -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 <memory>
|
||||
#include <vector>
|
||||
|
||||
#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<WorkspaceService>(*profile_);
|
||||
}
|
||||
|
||||
content::BrowserTaskEnvironment task_environment_;
|
||||
TestingProfileManager profile_manager_{TestingBrowserProcess::GetGlobal()};
|
||||
raw_ptr<Profile> profile_ = nullptr;
|
||||
std::unique_ptr<WorkspaceService> 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<std::unique_ptr<sessions::SessionCommand>>;
|
||||
|
||||
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<sessions::CommandStorageBackend>(
|
||||
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<sessions::CommandStorageBackend>(
|
||||
task_runner, workspace_path, kWorkspaceSessionType,
|
||||
/*encryptor=*/std::nullopt);
|
||||
base::test::TestFuture<CommandList> 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<sessions::CommandStorageBackend>(
|
||||
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<std::unique_ptr<sessions::SessionCommand>>(),
|
||||
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<KeyedService> {
|
||||
auto* p = Profile::FromBrowserContext(ctx);
|
||||
return std::make_unique<WorkspaceService>(*p);
|
||||
}));
|
||||
EXPECT_NE(WorkspaceServiceFactory::GetForProfile(profile), nullptr);
|
||||
}
|
||||
@@ -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 <algorithm>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#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<std::unique_ptr<sessions::SessionCommand>>& 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<tab_groups::TabGroupId> 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<std::unique_ptr<sessions::SessionCommand>>
|
||||
GenerateBrowserSessionCommandsForWorkspace(Profile* profile,
|
||||
WorkspaceMetadata& workspace) {
|
||||
std::vector<std::unique_ptr<sessions::SessionCommand>> 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<std::unique_ptr<sessions::SessionCommand>> 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<std::unique_ptr<sessions::SessionWindow>> windows;
|
||||
SessionID active_window_id = SessionID::InvalidValue();
|
||||
std::string platform_session_id;
|
||||
std::set<SessionID> 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();
|
||||
}
|
||||
}
|
||||
@@ -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 <memory>
|
||||
#include <vector>
|
||||
|
||||
#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<std::unique_ptr<sessions::SessionCommand>>
|
||||
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<std::unique_ptr<sessions::SessionCommand>> commands);
|
||||
|
||||
#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_SESSION_UTILS_H_
|
||||
@@ -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 <algorithm>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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<WorkspaceMetadata> ListWorkspacesFromDict(
|
||||
const base::DictValue& dict) {
|
||||
std::vector<WorkspaceMetadata> 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<std::unique_ptr<sessions::SessionCommand>> commands,
|
||||
const base::FilePath& workspace_path,
|
||||
scoped_refptr<sessions::CommandStorageBackend> 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<std::unique_ptr<sessions::SessionCommand>> ReadWorkspaceFromDisk(
|
||||
const base::FilePath& workspace_path,
|
||||
scoped_refptr<sessions::CommandStorageBackend> 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);
|
||||
}
|
||||
@@ -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 <memory>
|
||||
#include <vector>
|
||||
|
||||
#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<std::unique_ptr<sessions::SessionCommand>> commands,
|
||||
const base::FilePath& workspace_path,
|
||||
scoped_refptr<sessions::CommandStorageBackend> 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<std::unique_ptr<sessions::SessionCommand>> ReadWorkspaceFromDisk(
|
||||
const base::FilePath& workspace_path,
|
||||
scoped_refptr<sessions::CommandStorageBackend> backend);
|
||||
|
||||
// Parses a workspace metadata dict (the value stored under
|
||||
// kWorkspacesMetadataPref) and returns a list sorted by modified_at descending.
|
||||
std::vector<WorkspaceMetadata> 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_
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user