diff --git a/lib/bds/desktop/shell_commands.ex b/lib/bds/desktop/shell_commands.ex index 8528dd2..87146df 100644 --- a/lib/bds/desktop/shell_commands.ex +++ b/lib/bds/desktop/shell_commands.ex @@ -249,6 +249,13 @@ defmodule BDS.Desktop.ShellCommands do nil -> {:error, %{message: "No active project selected"}} project -> {:ok, project} end + rescue + error in [Exqlite.Error] -> + if String.contains?(Exception.message(error), "no such table: projects") do + {:error, %{message: "Project database is not initialized"}} + else + reraise error, __STACKTRACE__ + end end defp preview_url(server) do diff --git a/priv/ui/app.css b/priv/ui/app.css index 910c842..7851c69 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -537,8 +537,33 @@ button { .tab-close { margin-left: auto; - font-size: 11px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 15px; + line-height: 1; color: var(--vscode-descriptionForeground); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.tab-close:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-tab-activeForeground); +} + +.output-item-details { + margin: 4px 0 0; + padding: 8px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + color: inherit; + font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: pre-wrap; + user-select: text; } .editor-shell { @@ -936,16 +961,31 @@ button { gap: 8px; } +.panel-tabs { + display: flex; + gap: 2px; +} + .panel-tab { - padding: 8px 10px; - border-radius: 999px; background: transparent; - color: var(--muted); + border: none; + padding: 6px 12px; + font-size: 12px; + color: var(--vscode-tab-inactiveForeground); + cursor: pointer; + border-bottom: 2px solid transparent; + border-radius: 0; +} + +.panel-tab:hover { + color: var(--vscode-tab-activeForeground); + background: transparent; } .panel-tab.active { - background: var(--accent-soft); - color: var(--ink); + color: var(--vscode-tab-activeForeground); + border-bottom-color: var(--vscode-focusBorder); + background: transparent; } .assistant-content { @@ -956,29 +996,42 @@ button { } .status-bar { - height: 34px; + height: 22px; display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 0 12px; - background: var(--status); - border-top: 1px solid var(--line); + background-color: var(--vscode-statusBar-background); + color: var(--vscode-statusBar-foreground); + font-size: 12px; + padding: 0 8px; + user-select: none; + flex-wrap: nowrap; + gap: 0; + border-top: none; } .status-bar-left, .status-bar-right { display: flex; - gap: 8px; + align-items: center; + gap: 4px; + flex-shrink: 0; min-width: 0; } .status-bar-item { - max-width: 180px; - padding: 4px 8px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); - font-size: 11px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; + height: 100%; + max-width: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + border-radius: 0; + background: transparent; + font-size: 12px; } @media (max-width: 1100px) { diff --git a/priv/ui/app.js b/priv/ui/app.js index 66fd788..d3ea3bd 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -64,7 +64,7 @@ function renderTitlebar() { `)} - ${renderTitlebarAction("toggle-assistant", "toggle-assistant", "Toggle assistant", ` + ${renderTitlebarAction("toggle-assistant-sidebar", "toggle-assistant", "Toggle assistant", ` @@ -184,7 +184,7 @@ function renderTab(tab) { `; } @@ -419,11 +419,13 @@ function applyVisibility() { } function bindEvents() { - root.querySelectorAll("[data-command]").forEach((button) => { + root.querySelectorAll("button[data-command]").forEach((button) => { button.onclick = () => { const command = button.dataset.command; if (command === "open-tasks-panel") { openTasksPanel(); + render(); + return; } if (command === "toggle-offline-mode") { executeShellCommand("toggle_offline_mode"); @@ -471,6 +473,15 @@ function bindEvents() { }; }); + root.querySelectorAll("[data-close-tab]").forEach((button) => { + button.onclick = (event) => { + event.stopPropagation(); + const [type, id] = button.dataset.closeTab.split(":"); + closeSpecificTab(type, id); + render(); + }; + }); + root.querySelectorAll("[data-panel-tab]").forEach((button) => { button.onclick = () => { state.session.panel.active_tab = button.dataset.panelTab; @@ -790,6 +801,30 @@ function closeActiveTab() { } } +function closeSpecificTab(type, id) { + const index = state.session.tabs.findIndex((tab) => tab.type === type && tab.id === id); + + if (index < 0) { + return; + } + + const wasActive = state.session.active_tab?.type === type && state.session.active_tab?.id === id; + state.session.tabs.splice(index, 1); + delete state.tabMeta[`${type}:${id}`]; + + if (!state.session.tabs.length) { + state.session.active_tab = null; + return; + } + + if (!wasActive) { + return; + } + + const next = state.session.tabs[Math.min(index, state.session.tabs.length - 1)]; + state.session.active_tab = { type: next.type, id: next.id }; +} + function openTab(type, id, title, transient, meta = {}) { const existingIndex = state.session.tabs.findIndex((tab) => tab.type === type && tab.id === id); @@ -1187,7 +1222,7 @@ function renderLanguageOptions() { return state.supportedUiLanguages .map((language) => { const selected = language.code === state.uiLanguage ? " selected" : ""; - return ``; + return ``; }) .join(""); } diff --git a/scripts/desktop_automation_runner.mjs b/scripts/desktop_automation_runner.mjs index 731b945..c82e67a 100644 --- a/scripts/desktop_automation_runner.mjs +++ b/scripts/desktop_automation_runner.mjs @@ -43,6 +43,8 @@ for await (const line of rl) { window_title: text("[data-testid='window-title']"), active_view: document.querySelector("[data-testid='activity-button'][data-active='true']")?.dataset.view ?? null, sidebar_visible: !hasClass("[data-testid='sidebar-shell']", "is-hidden"), + assistant_visible: !hasClass("[data-testid='assistant-shell']", "is-hidden"), + panel_visible: !hasClass(".panel-shell", "is-hidden"), editor_title: text("[data-testid='editor-title']"), activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")), sidebar_sections: texts("[data-testid='sidebar-section-title']", (node) => node.textContent.trim()), diff --git a/test/bds/desktop/automation_test.exs b/test/bds/desktop/automation_test.exs index 7caa2c6..8383bef 100644 --- a/test/bds/desktop/automation_test.exs +++ b/test/bds/desktop/automation_test.exs @@ -22,6 +22,8 @@ defmodule BDS.Desktop.AutomationTest do assert snapshot.window_title == "Blogging Desktop Server" assert snapshot.active_view == "posts" assert snapshot.sidebar_visible == true + assert snapshot.assistant_visible == false + assert snapshot.panel_visible == false assert snapshot.editor_title == "Dashboard" assert snapshot.activity_labels == [ "Posts", @@ -43,6 +45,12 @@ defmodule BDS.Desktop.AutomationTest do snapshot = Automation.snapshot(session) assert snapshot.sidebar_visible == false + assert :ok = Automation.click(session, "[data-testid='toggle-assistant']") + + snapshot = Automation.snapshot(session) + assert snapshot.assistant_visible == true + assert snapshot.panel_visible == false + screenshot_path = Path.join(screenshot_dir, "main-window.png") assert Automation.capture_screenshot(session, screenshot_path) == screenshot_path assert File.exists?(screenshot_path) diff --git a/test/bds/desktop/shell_commands_test.exs b/test/bds/desktop/shell_commands_test.exs index ce2bb67..a04ad9e 100644 --- a/test/bds/desktop/shell_commands_test.exs +++ b/test/bds/desktop/shell_commands_test.exs @@ -73,6 +73,13 @@ defmodule BDS.Desktop.ShellCommandsTest do assert wait_for_task(result.task_id, &(&1.status in [:completed, :failed])).status == :completed end + test "missing project schema returns a command error instead of raising" do + BDS.Repo.query!("DROP TABLE projects", []) + + assert {:error, %{message: message}} = ShellCommands.execute("open_in_browser") + assert message =~ "Project database is not initialized" + end + defp wait_for_task(task_id, matcher, timeout \\ 2_000) defp wait_for_task(task_id, _matcher, timeout) when timeout <= 0 do diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index d802752..ce5b7aa 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -130,6 +130,7 @@ defmodule BDS.UI.ShellTest do assert js =~ "window-titlebar-sidebar-icon" assert js =~ "window-titlebar-panel-icon" assert js =~ "window-titlebar-assistant-icon" + assert js =~ "toggle-assistant-sidebar" assert js =~ "activity-bar-top" assert js =~ "activity-bar-bottom" end @@ -148,6 +149,7 @@ defmodule BDS.UI.ShellTest do assert css =~ "gap: 4px" assert css =~ "padding: 0 8px" assert css =~ "height: 100%" + refute css =~ "background: var(--status)" assert css =~ ".status-bar-language-select" assert css =~ ".status-bar-item.language-badge" assert css =~ ".status-bar-item.offline-badge" @@ -166,6 +168,9 @@ defmodule BDS.UI.ShellTest do assert js =~ "executeBackendShellCommand" assert js =~ "applyShellCommandResult" assert js =~ "openTasksPanel" + assert js =~ "command === \"open-tasks-panel\")" + assert js =~ "openTasksPanel();" + assert js =~ "return;" assert js =~ "No background tasks running" assert js =~ "task-list" assert js =~ "output-list" @@ -189,5 +194,8 @@ defmodule BDS.UI.ShellTest do assert js =~ "case \"metadata_diff\"" assert js =~ "case \"regenerate_calendar\"" assert js =~ "case \"fill_missing_translations\"" + assert js =~ "root.querySelectorAll(\"button[data-command]\")" + assert js =~ "[data-close-tab]" + assert js =~ "language.flag" end end