feat: persisted tabs

This commit is contained in:
2026-04-26 12:58:58 +02:00
parent b1592c49f4
commit dd9e6b73ae
6 changed files with 133 additions and 9 deletions

View File

@@ -13,7 +13,7 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.UI.{Commands, MenuBar, Registry, Workbench} alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench}
@refresh_interval 1_500 @refresh_interval 1_500
@output_entry_limit 20 @output_entry_limit 20
@@ -359,6 +359,10 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, set_page_language(socket, language)} {:noreply, set_page_language(socket, language)}
end end
def handle_event("restore_workbench_session", %{"session" => session_payload}, socket) when is_map(session_payload) do
{:noreply, reload_shell(socket, restore_workbench_session(session_payload))}
end
def handle_event("native_menu_action", %{"action" => action}, socket) do def handle_event("native_menu_action", %{"action" => action}, socket) do
{:noreply, handle_native_menu_action(socket, action)} {:noreply, handle_native_menu_action(socket, action)}
end end
@@ -1006,6 +1010,8 @@ defmodule BDS.Desktop.ShellLive do
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts) defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
defp encoded_workbench_session(workbench), do: Jason.encode!(Session.serialize(workbench))
defp panel_tab_label(:tasks), do: translated("Tasks") defp panel_tab_label(:tasks), do: translated("Tasks")
defp panel_tab_label(:output), do: translated("Output") defp panel_tab_label(:output), do: translated("Output")
defp panel_tab_label(:git_log), do: translated("Git Log") defp panel_tab_label(:git_log), do: translated("Git Log")
@@ -1319,7 +1325,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:project_menu_open, false) |> assign(:project_menu_open, false)
|> assign(:sidebar_filters_by_view, %{}) |> assign(:sidebar_filters_by_view, %{})
|> append_output_entry(title, message_fun.(project)) |> append_output_entry(title, message_fun.(project))
|> reload_shell(Workbench.clear_tabs(socket.assigns.workbench)) |> reload_shell(Workbench.new())
{:error, reason} -> {:error, reason} ->
socket socket
@@ -1357,6 +1363,12 @@ defmodule BDS.Desktop.ShellLive do
end end
end end
defp restore_workbench_session(session_payload) do
Session.restore(session_payload)
rescue
_error -> Workbench.new()
end
defp safe_existing_atom(action) when is_binary(action) do defp safe_existing_atom(action) when is_binary(action) do
String.to_existing_atom(action) String.to_existing_atom(action)
rescue rescue

View File

@@ -1,4 +1,11 @@
<div class="app" id="bds-shell-app" phx-hook="AppShell" data-shortcuts={encoded_shortcuts(@client_shortcuts)}> <div
class="app"
id="bds-shell-app"
phx-hook="AppShell"
data-shortcuts={encoded_shortcuts(@client_shortcuts)}
data-project-id={@projects.active_project_id || ""}
data-workbench-session={encoded_workbench_session(@workbench)}
>
<%= if @is_mac_ui do %> <%= if @is_mac_ui do %>
<span data-testid="window-title" hidden><%= @page_title %></span> <span data-testid="window-title" hidden><%= @page_title %></span>
<% end %> <% end %>

View File

@@ -32,18 +32,18 @@ defmodule BDS.UI.Session do
Workbench.new( Workbench.new(
sidebar_visible: Map.get(payload, "sidebar_visible", true), sidebar_visible: Map.get(payload, "sidebar_visible", true),
sidebar_width: Map.get(payload, "sidebar_width", 280), sidebar_width: Map.get(payload, "sidebar_width", 280),
active_view: Map.get(payload, "active_view", "posts"), active_view: atomize(Map.get(payload, "active_view"), :posts),
assistant_sidebar_visible: Map.get(payload, "assistant_sidebar_visible", false), assistant_sidebar_visible: Map.get(payload, "assistant_sidebar_visible", false),
assistant_sidebar_width: Map.get(payload, "assistant_sidebar_width", 360), assistant_sidebar_width: Map.get(payload, "assistant_sidebar_width", 360),
panel_visible: get_in(payload, ["panel", "visible"]) || false, panel_visible: get_in(payload, ["panel", "visible"]) || false,
panel_tab: atomize(get_in(payload, ["panel", "active_tab"]) || "tasks"), panel_tab: atomize(get_in(payload, ["panel", "active_tab"]) || "tasks", :tasks),
dirty_tabs: Enum.map(Map.get(payload, "dirty_tabs", []), &decode_tab_ref/1) dirty_tabs: Enum.map(Map.get(payload, "dirty_tabs", []), &decode_tab_ref/1)
) )
tabs = tabs =
Enum.map(Map.get(payload, "tabs", []), fn tab -> Enum.map(Map.get(payload, "tabs", []), fn tab ->
%{ %{
type: atomize(Map.get(tab, "type", "post")), type: atomize(Map.get(tab, "type", "post"), :post),
id: Map.get(tab, "id"), id: Map.get(tab, "id"),
is_transient: Map.get(tab, "is_transient", false) is_transient: Map.get(tab, "is_transient", false)
} }
@@ -58,10 +58,15 @@ defmodule BDS.UI.Session do
defp encode_tab_ref({type, id}), do: %{"type" => Atom.to_string(type), "id" => id} defp encode_tab_ref({type, id}), do: %{"type" => Atom.to_string(type), "id" => id}
defp decode_tab_ref(nil), do: nil defp decode_tab_ref(nil), do: nil
defp decode_tab_ref(%{"type" => type, "id" => id}), do: {atomize(type), id} defp decode_tab_ref(%{"type" => type, "id" => id}), do: {atomize(type, :post), id}
defp atomize(value) when is_atom(value), do: value defp atomize(value, _fallback) when is_atom(value), do: value
defp atomize(value) when is_binary(value), do: String.to_atom(value)
defp atomize(value, fallback) when is_binary(value) do
String.to_existing_atom(value)
rescue
ArgumentError -> fallback
end
defp active_route(nil), do: :dashboard defp active_route(nil), do: :dashboard
defp active_route({type, _id}), do: type defp active_route({type, _id}), do: type

View File

@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar"; const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar"; const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language"; const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language";
const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-";
const parseShortcutConfig = (value) => { const parseShortcutConfig = (value) => {
if (!value) { if (!value) {
@@ -20,6 +21,19 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}; };
const parseJsonObject = (value) => {
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch (_error) {
return null;
}
};
const normalizeShortcutKey = (key) => String(key || "").toLowerCase(); const normalizeShortcutKey = (key) => String(key || "").toLowerCase();
const shortcutTargetIsEditable = (event) => { const shortcutTargetIsEditable = (event) => {
@@ -125,10 +139,44 @@ document.addEventListener("DOMContentLoaded", () => {
AppShell: { AppShell: {
mounted() { mounted() {
this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts); this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts);
this.currentProjectId = this.el.dataset.projectId || "";
this.syncStoredLayout(); this.syncStoredLayout();
this.syncStoredUiLanguage(); this.syncStoredUiLanguage();
this.destroyOverlaySync = syncTitlebarOverlayInsets(); this.destroyOverlaySync = syncTitlebarOverlayInsets();
this.workbenchStorageKey = (projectId) =>
projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null;
this.restoreStoredWorkbenchSession = () => {
const projectId = this.el.dataset.projectId || "";
const storageKey = this.workbenchStorageKey(projectId);
if (!storageKey) {
return false;
}
const session = parseJsonObject(window.localStorage.getItem(storageKey));
if (!session) {
return false;
}
this.pushEvent("restore_workbench_session", { session });
return true;
};
this.persistWorkbenchSession = () => {
const projectId = this.el.dataset.projectId || "";
const storageKey = this.workbenchStorageKey(projectId);
const session = this.el.dataset.workbenchSession;
if (!storageKey || !session) {
return;
}
window.localStorage.setItem(storageKey, session);
};
this.handleMouseDown = (event) => { this.handleMouseDown = (event) => {
const handle = event.target.closest("[data-role='resize-handle']"); const handle = event.target.closest("[data-role='resize-handle']");
@@ -219,6 +267,21 @@ document.addEventListener("DOMContentLoaded", () => {
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true); window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("change", this.handleChange); this.el.addEventListener("change", this.handleChange);
this.restoreStoredWorkbenchSession();
},
updated() {
const nextProjectId = this.el.dataset.projectId || "";
if (nextProjectId !== this.currentProjectId) {
this.currentProjectId = nextProjectId;
if (this.restoreStoredWorkbenchSession()) {
return;
}
}
this.persistWorkbenchSession();
}, },
destroyed() { destroyed() {

View File

@@ -10,6 +10,7 @@ defmodule BDS.Desktop.ShellLiveTest do
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
alias BDS.Tags alias BDS.Tags
alias BDS.UI.{Session, Workbench}
@endpoint BDS.Desktop.Endpoint @endpoint BDS.Desktop.Endpoint
@@ -205,6 +206,27 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown") refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
end end
test "workbench session restore reopens permanent and transient tabs and selected activity" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
session_payload =
Workbench.new()
|> Workbench.click_activity(:media)
|> Workbench.open_tab(:post, "post-1", :pin)
|> Workbench.open_tab(:media, "media-1", :preview)
|> Session.serialize()
html = render_hook(view, "restore_workbench_session", %{"session" => session_payload})
assert html =~ ~s(data-view="media")
assert html =~ ~s(data-active="true")
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="post-1")
assert html =~ ~s(data-tab-type="media")
assert html =~ ~s(data-tab-id="media-1")
assert html =~ ~s(class="tab active transient")
end
test "shell live renders the legacy git activity badge from remote behind count" do test "shell live renders the legacy git activity badge from remote behind count" do
Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts -> Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts ->
{:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}} {:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}}

View File

@@ -103,6 +103,7 @@ defmodule BDS.UI.ShellTest do
test "desktop shell keeps the compact frame metrics and live bootstrap assets" do test "desktop shell keeps the compact frame metrics and live bootstrap assets" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js") live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css") assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/live.js") assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/live.js")
@@ -113,6 +114,20 @@ defmodule BDS.UI.ShellTest do
assert css =~ "height: 22px" assert css =~ "height: 22px"
assert live_js =~ "LiveView.LiveSocket" assert live_js =~ "LiveView.LiveSocket"
assert live_js =~ "Phoenix.Socket" assert live_js =~ "Phoenix.Socket"
assert template =~ "data-project-id={@projects.active_project_id || \"\"}"
assert template =~ "data-workbench-session={encoded_workbench_session(@workbench)}"
end
test "desktop shell assets persist workbench layout per project" do
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex")
assert live_js =~ "bds-workbench-"
assert live_js =~ "restore_workbench_session"
assert live_js =~ "dataset.workbenchSession"
assert live_ex =~ ~s(def handle_event("restore_workbench_session")
assert live_ex =~ "Session.restore"
assert live_ex =~ "encoded_workbench_session"
end end
test "desktop shell css keeps the status bar and hidden menu alignment rules" do test "desktop shell css keeps the status bar and hidden menu alignment rules" do