From 906bad6aa46f1984b2becccaddce043098d86bb7 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 24 Apr 2026 15:56:37 +0200 Subject: [PATCH] feat: finally a halfway working prototype --- config/config.exs | 4 +- lib/bds/application.ex | 20 +- lib/bds/desktop/main_window.ex | 221 ++++++++ lib/bds/desktop/menu.ex | 2 +- lib/bds/ui/shell_page.ex | 2 +- priv/ui/app.css | 699 +++++++++++++++++++------- priv/ui/app.js | 192 +++++-- priv/ui/index.html | 6 +- test/bds/desktop/main_window_test.exs | 58 +++ test/bds/desktop_test.exs | 5 +- test/bds/ui/shell_test.exs | 17 + 11 files changed, 998 insertions(+), 228 deletions(-) create mode 100644 test/bds/desktop/main_window_test.exs diff --git a/config/config.exs b/config/config.exs index 31313bb..adf8ce5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -13,8 +13,8 @@ config :bds, BDS.Application, desktop_adapter: :desktop config :bds, :desktop, port: 4010, - window_size: {1440, 900}, - window_min_size: {1100, 700}, + window_size: {1280, 780}, + window_min_size: {800, 600}, title: "Blogging Desktop Server", secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001" diff --git a/lib/bds/application.ex b/lib/bds/application.ex index c8b246a..7ee52c2 100644 --- a/lib/bds/application.ex +++ b/lib/bds/application.ex @@ -48,18 +48,16 @@ defmodule BDS.Application do if desktop_automation?() do [] else + window_opts = + BDS.Desktop.MainWindow.window_options( + menubar: BDS.Desktop.MenuBar, + icon_menu: BDS.Desktop.Menu, + url: &BDS.Desktop.url/0 + ) + [ - {Desktop.Window, - [ - app: :bds, - id: BDS.Desktop.MainWindow, - title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server", - size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900}, - min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700}, - menubar: BDS.Desktop.MenuBar, - icon_menu: BDS.Desktop.Menu, - url: &BDS.Desktop.url/0 - ]} + {Desktop.Window, window_opts}, + Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher) ] end end diff --git a/lib/bds/desktop/main_window.ex b/lib/bds/desktop/main_window.ex index 25c6c9d..2ba51c7 100644 --- a/lib/bds/desktop/main_window.ex +++ b/lib/bds/desktop/main_window.ex @@ -1,3 +1,224 @@ defmodule BDS.Desktop.MainWindow do @moduledoc false + + use GenServer + + alias Desktop.Window + + @window_id __MODULE__ + @persist_interval_ms 1_000 + @default_size {1280, 780} + @default_min_size {800, 600} + @state_file "window-state.json" + + def start_link(_opts) do + GenServer.start_link(__MODULE__, :ok) + end + + def window_id, do: @window_id + + def window_options(extra_opts \\ []) do + desktop_config = Application.get_env(:bds, :desktop, []) + restored = restore_bounds() + {default_width, default_height} = Keyword.get(desktop_config, :window_size, @default_size) + {min_width, min_height} = Keyword.get(desktop_config, :window_min_size, @default_min_size) + startup_bounds = clamp_startup_bounds(restored || %{width: default_width, height: default_height}) + + base_opts = [ + app: :bds, + id: window_id(), + title: Keyword.get(desktop_config, :title, "Blogging Desktop Server"), + size: {startup_bounds.width, startup_bounds.height}, + min_size: {min_width, min_height} + ] + + Keyword.merge(base_opts, extra_opts) + end + + def restore_bounds do + with path when is_binary(path) <- window_state_path(), + true <- File.exists?(path), + {:ok, body} <- File.read(path), + {:ok, decoded} <- Jason.decode(body), + {:ok, bounds} <- normalize_bounds(decoded) do + clamp_startup_bounds(bounds) + else + _ -> nil + end + end + + def persist_bounds(%{x: _x, y: _y, width: _width, height: _height} = bounds) do + path = window_state_path() + File.mkdir_p!(Path.dirname(path)) + File.write(path, Jason.encode!(bounds)) + end + + @impl true + def init(:ok) do + Process.flag(:trap_exit, true) + send(self(), :attach_window) + {:ok, %{frame: nil, last_bounds: restore_bounds()}} + end + + @impl true + def handle_info(:attach_window, state) do + case lookup_frame() do + nil -> + Process.send_after(self(), :attach_window, 200) + {:noreply, state} + + frame -> + apply_restored_bounds(frame) + schedule_persist() + {:noreply, %{state | frame: frame, last_bounds: current_bounds(frame) || state.last_bounds}} + end + end + + def handle_info(:persist_bounds, %{frame: frame} = state) do + next_bounds = current_bounds(frame) || state.last_bounds + + if next_bounds && next_bounds != state.last_bounds do + :ok = persist_bounds(next_bounds) + end + + schedule_persist() + {:noreply, %{state | last_bounds: next_bounds}} + end + + @impl true + def terminate(_reason, %{frame: frame, last_bounds: last_bounds}) do + if bounds = current_bounds(frame) || last_bounds do + _ = persist_bounds(bounds) + end + + :ok + end + + defp schedule_persist do + Process.send_after(self(), :persist_bounds, @persist_interval_ms) + end + + defp apply_restored_bounds(frame) do + case restore_bounds() do + %{x: x, y: y, width: width, height: height} -> + with_wx_env(fn -> + :wxWindow.setSize(frame, {width, height}) + :wxWindow.move(frame, {x, y}) + end) + + _ -> + :ok + end + end + + defp lookup_frame do + try do + Window.frame(window_id()) + catch + :exit, _ -> nil + end + end + + defp current_bounds(nil), do: nil + + defp current_bounds(frame) do + with_wx_env(fn -> + cond do + not :wxWindow.isShown(frame) -> nil + :wxTopLevelWindow.isFullScreen(frame) -> nil + :wxTopLevelWindow.isMaximized(frame) -> nil + true -> + {x, y} = :wxWindow.getPosition(frame) + {width, height} = :wxWindow.getSize(frame) + %{x: x, y: y, width: width, height: height} + end + end) + end + + defp with_wx_env(fun) do + :wx.set_env(Desktop.Env.wx_env()) + fun.() + end + + defp window_state_path do + desktop_config = Application.get_env(:bds, :desktop, []) + + Keyword.get_lazy(desktop_config, :window_state_path, fn -> + Path.join(config_dir(), @state_file) + end) + end + + defp config_dir do + case :filename.basedir(:user_config, "bds") do + path when is_list(path) -> List.to_string(path) + path -> path + end + end + + defp normalize_bounds(%{"x" => x, "y" => y, "width" => width, "height" => height}) do + normalize_bounds(%{x: x, y: y, width: width, height: height}) + end + + defp normalize_bounds(%{x: x, y: y, width: width, height: height}) + when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) and width > 0 and height > 0 do + {:ok, %{x: x, y: y, width: width, height: height}} + end + + defp normalize_bounds(_value), do: :error + + defp clamp_startup_bounds(bounds) do + case client_area() do + %{width: area_width, height: area_height} -> + %{bounds | width: min(bounds.width, area_width), height: min(bounds.height, area_height)} + + nil -> + bounds + end + end + + defp client_area do + desktop_config = Application.get_env(:bds, :desktop, []) + + case Keyword.get(desktop_config, :window_client_area_override) do + {x, y, width, height} when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) -> + %{x: x, y: y, width: width, height: height} + + _ -> + read_client_area_from_wx() + end + end + + defp read_client_area_from_wx do + created_wx? = wx_env_undefined?() + + try do + if created_wx?, do: :wx.new() + + display = :wxDisplay.new() + + if :wxDisplay.isOk(display) do + {x, y, width, height} = :wxDisplay.getClientArea(display) + :wxDisplay.destroy(display) + %{x: x, y: y, width: width, height: height} + else + :wxDisplay.destroy(display) + nil + end + rescue + _ -> nil + after + if created_wx?, do: :wx.destroy() + end + end + + defp wx_env_undefined? do + try do + case :wx.get_env() do + :undefined -> true + _ -> false + end + rescue + ErlangError -> true + end + end end diff --git a/lib/bds/desktop/menu.ex b/lib/bds/desktop/menu.ex index c98635a..fa77484 100644 --- a/lib/bds/desktop/menu.ex +++ b/lib/bds/desktop/menu.ex @@ -22,7 +22,7 @@ defmodule BDS.Desktop.Menu do @impl true def handle_event("open", menu) do - Window.show(BDS.Desktop.MainWindow) + Window.show(BDS.Desktop.MainWindow.window_id()) {:noreply, menu} end diff --git a/lib/bds/ui/shell_page.ex b/lib/bds/ui/shell_page.ex index 8da989c..e822e8a 100644 --- a/lib/bds/ui/shell_page.ex +++ b/lib/bds/ui/shell_page.ex @@ -48,7 +48,7 @@ defmodule BDS.UI.ShellPage do end defp bootstrap do - workbench = Workbench.new(panel_visible: true, assistant_sidebar_visible: true) + workbench = Workbench.new() %{ title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server", diff --git a/priv/ui/app.css b/priv/ui/app.css index 283808e..b422624 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -1,20 +1,35 @@ :root { - --bg: #11161d; - --panel: #171d25; - --panel-2: #1d2530; - --panel-3: #202a36; - --ink: #edf2f7; - --muted: #95a2b3; - --line: rgba(173, 189, 204, 0.14); - --accent: #4fb3ff; - --accent-soft: rgba(79, 179, 255, 0.16); - --success: #6ecb8b; - --status: #10151b; - --shadow: 0 18px 48px rgba(0, 0, 0, 0.32); + --vscode-editor-background: #1e1e1e; + --vscode-sideBar-background: #252526; + --vscode-activityBar-background: #333333; + --vscode-panel-background: #1e1e1e; + --vscode-titleBar-activeBackground: #252526; + --vscode-titleBar-activeForeground: #cccccc; + --vscode-statusBar-background: #007acc; + --vscode-statusBar-foreground: #ffffff; + --vscode-tab-activeBackground: #1e1e1e; + --vscode-tab-inactiveBackground: #2d2d2d; + --vscode-tab-activeForeground: #ffffff; + --vscode-tab-inactiveForeground: #969696; + --vscode-editorGroupHeader-tabsBackground: #252526; + --vscode-editorGroupHeader-tabsBorder: #1e1e1e; + --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); + --vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31); + --vscode-foreground: #cccccc; + --vscode-descriptionForeground: #858585; + --vscode-panel-border: #80808059; + --vscode-sideBar-border: #80808059; + --vscode-tab-border: #252526; + --vscode-focusBorder: #007fd4; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-activeSelectionBackground: #094771; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-activityBarBadge-background: #007acc; + --vscode-activityBarBadge-foreground: #ffffff; --sidebar-width: 280px; --assistant-width: 360px; color-scheme: dark; - font-family: "Avenir Next", "Segoe UI", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } * { @@ -26,15 +41,13 @@ body { margin: 0; width: 100%; height: 100%; - background: - radial-gradient(circle at top left, rgba(79, 179, 255, 0.12), transparent 26%), - radial-gradient(circle at bottom right, rgba(110, 203, 139, 0.12), transparent 24%), - linear-gradient(180deg, #0b1016 0%, #121922 100%); - color: var(--ink); + background: var(--vscode-editor-background); + color: var(--vscode-foreground); } body { overflow: hidden; + user-select: none; } button { @@ -46,118 +59,249 @@ button { height: 100%; display: flex; flex-direction: column; - background: rgba(17, 22, 29, 0.96); + background-color: var(--vscode-editor-background); +} + +.app-main { + flex: 1; + display: flex; + overflow: hidden; + min-height: 0; +} + +.app-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; } .window-titlebar { - height: 38px; + position: relative; + height: 34px; display: flex; align-items: center; justify-content: space-between; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), var(--panel); - border-bottom: 1px solid var(--line); - -webkit-app-region: drag; + background-color: var(--vscode-editorGroupHeader-tabsBackground); + border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder); + flex-shrink: 0; app-region: drag; + -webkit-app-region: drag; + padding-right: 10px; } -.window-titlebar-menu-bar, -.window-titlebar-actions { +.window-titlebar-menu-bar { display: flex; align-items: center; - gap: 6px; - padding: 0 10px; - -webkit-app-region: no-drag; + height: 100%; + margin-left: 6px; + gap: 2px; app-region: no-drag; + -webkit-app-region: no-drag; + z-index: 2; +} + +.window-titlebar-menu-button { + height: 24px; + border: none; + background: transparent; + color: var(--vscode-titleBar-activeForeground); + padding: 0 8px; + border-radius: 4px; + font-size: 12px; + line-height: 1; + cursor: pointer; +} + +.window-titlebar-menu-button:hover, +.window-titlebar-action-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.window-titlebar-drag-region { + flex: 1; + height: 100%; } .window-titlebar-title { position: absolute; left: 50%; transform: translateX(-50%); - font-size: 12px; - letter-spacing: 0.04em; - color: var(--muted); -} - -.window-titlebar-menu-button, -.window-titlebar-action-button, -.panel-tab, -.tab, -.sidebar-item, -.activity-bar-item { - border: none; - cursor: pointer; -} - -.window-titlebar-menu-button, -.window-titlebar-action-button { - min-height: 26px; - padding: 0 10px; - border-radius: 8px; - background: transparent; - color: var(--muted); -} - -.window-titlebar-menu-button:hover, -.window-titlebar-action-button:hover { - background: rgba(255, 255, 255, 0.06); - color: var(--ink); -} - -.app-main { - flex: 1; + max-width: 45%; + height: 100%; display: flex; - min-height: 0; + align-items: center; + justify-content: center; + color: var(--vscode-titleBar-activeForeground); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + pointer-events: none; +} + +.window-titlebar-actions { + height: 100%; + display: flex; + align-items: center; + margin-right: 6px; + app-region: no-drag; + -webkit-app-region: no-drag; +} + +.window-titlebar-action-button { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + line-height: 0; + background: transparent; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + border-radius: 4px; +} + +.window-titlebar-sidebar-icon, +.window-titlebar-panel-icon, +.window-titlebar-assistant-icon { + width: 14px; + height: 14px; + border: 1.5px solid currentColor; + border-radius: 2px; + display: block; + position: relative; + overflow: hidden; +} + +.window-titlebar-sidebar-icon::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 33.3333%; + width: 1.5px; + transform: translateX(-50%); + background-color: currentColor; +} + +.window-titlebar-panel-icon::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 66.6667%; + height: 1.5px; + transform: translateY(-50%); + background-color: currentColor; +} + +.window-titlebar-assistant-icon::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 66.6667%; + width: 1.5px; + transform: translateX(-50%); + background-color: currentColor; +} + +.window-titlebar-sidebar-pane, +.window-titlebar-panel-pane, +.window-titlebar-assistant-pane { + position: absolute; + background-color: currentColor; + transition: opacity 120ms ease; +} + +.window-titlebar-sidebar-pane { + left: 0; + top: 0; + width: 33.3333%; + height: 100%; +} + +.window-titlebar-panel-pane { + left: 0; + bottom: 0; + width: 100%; + height: 33.3333%; +} + +.window-titlebar-assistant-pane { + right: 0; + top: 0; + width: 33.3333%; + height: 100%; +} + +.window-titlebar-sidebar-icon.is-inactive .window-titlebar-sidebar-pane, +.window-titlebar-panel-icon.is-inactive .window-titlebar-panel-pane, +.window-titlebar-assistant-icon.is-inactive .window-titlebar-assistant-pane { + opacity: 0; } .activity-bar { - width: 56px; + width: 48px; + height: 100%; + background-color: var(--vscode-activityBar-background); display: flex; flex-direction: column; justify-content: space-between; - background: #0d1319; - border-right: 1px solid var(--line); + border-right: 1px solid var(--vscode-panel-border); } -.activity-bar-group { +.activity-bar-top, +.activity-bar-bottom { display: flex; flex-direction: column; align-items: center; - padding: 8px 0; - gap: 4px; + padding: 4px 0; } .activity-bar-item { - width: 42px; - height: 42px; - display: grid; - place-items: center; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; background: transparent; - color: var(--muted); - border-radius: 12px; + border: none; + color: var(--vscode-titleBar-activeForeground); + opacity: 0.6; + cursor: pointer; position: relative; + padding: 0; + border-radius: 0; +} + +.activity-bar-item:hover { + opacity: 1; } -.activity-bar-item:hover, .activity-bar-item.active { - color: var(--ink); - background: rgba(255, 255, 255, 0.06); + opacity: 1; } .activity-bar-item.active::before { content: ""; position: absolute; - left: -7px; - top: 8px; - bottom: 8px; - width: 3px; - border-radius: 999px; - background: var(--accent); + left: 0; + top: 0; + bottom: 0; + width: 2px; + background-color: var(--vscode-titleBar-activeForeground); } -.activity-bar-glyph { - font-size: 12px; - font-weight: 700; +.activity-bar-item svg, +.tab-icon svg { + display: block; } .sidebar-shell, @@ -174,32 +318,22 @@ button { width: var(--assistant-width); } -.sidebar, -.assistant-sidebar, -.panel-shell, -.editor-shell { - background: var(--panel); -} - .sidebar, .assistant-sidebar { width: 100%; + height: 100%; + background: var(--vscode-sideBar-background); display: flex; flex-direction: column; min-width: 0; } .sidebar { - border-right: 1px solid var(--line); + border-right: 1px solid var(--vscode-sideBar-border); } .assistant-sidebar { - border-left: 1px solid var(--line); -} - -.resizable-panel-divider { - width: 1px; - background: var(--line); + border-left: 1px solid var(--vscode-sideBar-border); } .sidebar-shell.is-hidden, @@ -213,60 +347,184 @@ button { display: none; } -.app-content { - flex: 1; +.resizable-panel-divider { + width: 4px; + cursor: col-resize; + background: transparent; + position: relative; +} + +.resizable-panel-divider:hover::after { + background-color: var(--vscode-focusBorder); +} + +.resizable-panel-divider::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 1px; + width: 1px; + background-color: var(--vscode-panel-border); +} + +.sidebar-header, +.assistant-header { + padding: 10px 12px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.sidebar-title-row, +.assistant-header { display: flex; flex-direction: column; - min-width: 0; + gap: 2px; +} + +.sidebar-subtitle, +.assistant-card span, +.panel-entry span, +.editor-meta-row span, +.editor-subtitle, +.sidebar-item span { + color: var(--vscode-descriptionForeground); +} + +.sidebar-content, +.assistant-content { + flex: 1; + overflow: auto; + padding: 8px 0; +} + +.sidebar-section { + padding-bottom: 10px; +} + +.sidebar-section-header { + padding: 0 12px 6px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); +} + +.sidebar-item { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 7px 12px; + border: none; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + text-align: left; +} + +.sidebar-item:hover { + background: var(--vscode-list-hoverBackground); +} + +.sidebar-item.active { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.sidebar-badge { + margin-top: 2px; + padding: 1px 6px; + border-radius: 10px; + font-size: 11px; + background: rgba(255, 255, 255, 0.08); } .tab-bar { - height: 40px; display: flex; align-items: center; - background: var(--panel-2); - border-bottom: 1px solid var(--line); - padding: 0 10px; + background-color: var(--vscode-editorGroupHeader-tabsBackground); + border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder); + height: 35px; + overflow: hidden; + flex-shrink: 0; + position: relative; } .tab-bar-tabs { display: flex; - align-items: stretch; - gap: 6px; + align-items: center; + height: 100%; overflow-x: auto; + overflow-y: hidden; + flex: 1; +} + +.tab-bar-tabs::-webkit-scrollbar { + height: 0; + display: none; } .tab-bar-empty { + display: flex; + align-items: center; + height: 100%; + padding: 0 12px; + color: var(--vscode-descriptionForeground); font-size: 12px; - color: var(--muted); } .tab { - min-width: 140px; - max-width: 200px; - padding: 0 12px; display: flex; align-items: center; - justify-content: space-between; - gap: 10px; - background: var(--panel); - color: var(--muted); - border-radius: 10px 10px 0 0; + gap: 4px; + padding: 0 10px; + height: 100%; + min-width: 100px; + max-width: 180px; + cursor: pointer; + background-color: var(--vscode-tab-inactiveBackground); + border: none; + border-right: 1px solid var(--vscode-tab-border); + color: var(--vscode-tab-inactiveForeground); + font-size: 13px; + user-select: none; + position: relative; + flex-shrink: 0; +} + +.tab:hover { + background-color: var(--vscode-list-hoverBackground); } .tab.active { - color: var(--ink); - background: #121922; - box-shadow: inset 0 2px 0 var(--accent); + background-color: var(--vscode-tab-activeBackground); + color: var(--vscode-tab-activeForeground); +} + +.tab.active::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background-color: var(--vscode-focusBorder); } .tab.transient .tab-title { font-style: italic; } +.tab-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0.85; +} + .tab-title, -.sidebar-item strong, -.sidebar-item span, .status-bar-item { overflow: hidden; text-overflow: ellipsis; @@ -274,88 +532,187 @@ button { } .tab-close { + margin-left: auto; font-size: 11px; - color: var(--muted); + color: var(--vscode-descriptionForeground); } .editor-shell { flex: 1; min-height: 0; - padding: 22px; overflow: auto; + background: var(--vscode-editor-background); } .editor-frame { display: grid; - grid-template-columns: minmax(0, 1fr) 280px; - gap: 20px; + grid-template-columns: minmax(0, 1fr) 240px; + gap: 16px; + padding: 14px 16px; } .editor-main, -.editor-meta-card, -.assistant-card, -.panel-entry, -.dashboard-card { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), var(--panel-2); - border: 1px solid var(--line); - border-radius: 18px; - box-shadow: var(--shadow); +.editor-meta, +.panel-shell, +.assistant-card { + min-width: 0; } -.editor-main { - padding: 24px; -} - -.editor-kicker, -.sidebar-eyebrow, -.sidebar-section-header, -.assistant-header, -.panel-header, -.sidebar-subtitle { +.editor-kicker { font-size: 11px; - letter-spacing: 0.08em; text-transform: uppercase; - color: var(--muted); + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); } .editor-title { - margin: 10px 0 8px; - font-size: 34px; - line-height: 1.1; + margin: 10px 0 6px; + font-size: 24px; + font-weight: 600; } .editor-subtitle { - margin: 0 0 22px; - color: var(--muted); + margin: 0 0 14px; } -.dashboard-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; +.editor-toolbar { + display: flex; + gap: 8px; + margin-bottom: 14px; } -.dashboard-card { - padding: 18px; +.editor-toolbar-button { + border: 1px solid var(--vscode-panel-border); + background: transparent; + color: var(--vscode-foreground); + padding: 4px 8px; + border-radius: 3px; } -.dashboard-card span, -.assistant-card span, -.panel-entry span, -.editor-meta-card span, -.sidebar-item span, -.sidebar-subtitle { - color: var(--muted); -} - -.dashboard-card strong { - display: block; - margin: 8px 0; - font-size: 32px; +.editor-toolbar-button:hover, +.panel-tab:hover { + background: var(--vscode-toolbar-hoverBackground); } .editor-section { - margin-top: 22px; + padding-top: 4px; +} + +.editor-section h2 { + margin: 0 0 8px; + font-size: 16px; +} + +.editor-list { + margin: 0; + padding-left: 18px; + line-height: 1.5; +} + +.editor-list.compact li { + margin-bottom: 6px; +} + +.editor-meta { + border-left: 1px solid var(--vscode-panel-border); + padding-left: 16px; +} + +.editor-meta-row { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 0; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.panel-shell { + height: 200px; + border-top: 1px solid var(--vscode-panel-border); + background: var(--vscode-panel-background); + display: flex; + flex-direction: column; +} + +.panel-shell.is-hidden { + display: none; +} + +.panel-header { + height: 35px; + display: flex; + align-items: center; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.panel-tabs { + display: flex; + align-items: stretch; + height: 100%; +} + +.panel-tab { + border: none; + background: transparent; + color: var(--vscode-descriptionForeground); + padding: 0 12px; + cursor: pointer; +} + +.panel-tab.active { + color: var(--vscode-tab-activeForeground); +} + +.panel-content { + flex: 1; + overflow: auto; + padding: 12px 14px; +} + +.panel-entry, +.assistant-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.status-bar { + height: 22px; + background: var(--vscode-statusBar-background); + color: var(--vscode-statusBar-foreground); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px; + font-size: 12px; + flex-shrink: 0; +} + +.status-bar-left, +.status-bar-right { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.status-bar-item.brand { + font-weight: 600; +} + +@media (max-width: 960px) { + .editor-frame { + grid-template-columns: minmax(0, 1fr); + } + + .editor-meta { + border-left: none; + border-top: 1px solid var(--vscode-panel-border); + padding-left: 0; + padding-top: 10px; + } } .editor-section ul { diff --git a/priv/ui/app.js b/priv/ui/app.js index 980b8a8..542c202 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -5,9 +5,11 @@ if (!root || !bootstrapNode) { throw new Error("Missing shell bootstrap payload"); } +const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar"; +const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar"; const bootstrap = JSON.parse(bootstrapNode.textContent); const state = { - session: clone(bootstrap.session), + session: hydrateSession(clone(bootstrap.session)), tabMeta: {}, }; @@ -15,10 +17,7 @@ render(); function render() { root.style.setProperty("--sidebar-width", state.session.sidebar_visible ? `${state.session.sidebar_width}px` : "0px"); - root.style.setProperty( - "--assistant-width", - state.session.assistant_sidebar_visible ? `${state.session.assistant_sidebar_width}px` : "0px" - ); + root.style.setProperty("--assistant-width", state.session.assistant_sidebar_visible ? `${state.session.assistant_sidebar_width}px` : "0px"); renderTitlebar(); renderActivityBar(); @@ -39,22 +38,43 @@ function renderTitlebar() { .map((group) => ``) .join("")} +
${escapeHtml(bootstrap.title)}
- - - + ${renderTitlebarAction("toggle-sidebar", "toggle-sidebar", "Toggle sidebar", ` + + + + `)} + ${renderTitlebarAction("toggle-panel", "toggle-panel", "Toggle panel", ` + + + + `)} + ${renderTitlebarAction("toggle-assistant", "toggle-assistant", "Toggle assistant", ` + + + + `)}
`; } +function renderTitlebarAction(command, testId, label, iconMarkup) { + return ` + + `; +} + function renderActivityBar() { const top = sidebarViews().filter((view) => view.activity_group === "top"); const bottom = sidebarViews().filter((view) => view.activity_group === "bottom"); root.querySelector(".activity-bar").innerHTML = ` -
${top.map(renderActivityButton).join("")}
-
${bottom.map(renderActivityButton).join("")}
+
${top.map(renderActivityButton).join("")}
+
${bottom.map(renderActivityButton).join("")}
`; } @@ -71,7 +91,7 @@ function renderActivityButton(view) { aria-label="${escapeHtml(view.label)}" title="${escapeHtml(view.label)}" > - ${escapeHtml(view.label.slice(0, 1))} + ${activityIcon(view.id)} `; } @@ -82,11 +102,10 @@ function renderSidebar() { root.querySelector(".sidebar").innerHTML = `