feat: more UI cleanup

This commit is contained in:
2026-04-24 18:22:25 +02:00
parent e51566d707
commit 6824b89691
11 changed files with 696 additions and 3 deletions

View File

@@ -25,6 +25,7 @@ defmodule BDS.Application do
def start(_type, _args) do
children = [
BDS.Repo,
BDS.RepoBootstrap,
BDS.Tasks,
BDS.Preview,
BDS.Publishing,

View File

@@ -36,6 +36,21 @@ defmodule BDS.Desktop.Router do
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.task_status_json())
end
get "/api/projects" do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.projects_json())
end
post "/api/projects" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.upsert_project_json(payload))
end
post "/api/commands" do
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body)

View File

@@ -9,6 +9,25 @@ defmodule BDS.Desktop.ShellController do
Jason.encode!(BDS.Tasks.status_snapshot())
end
def projects_json do
Jason.encode!(BDS.Projects.shell_snapshot())
rescue
error in [Exqlite.Error] ->
if String.contains?(Exception.message(error), "no such table: projects") do
Jason.encode!(default_project_snapshot())
else
reraise error, __STACKTRACE__
end
end
def upsert_project_json(payload) when is_map(payload) do
case normalize_project_request(payload) do
{:create, attrs} -> create_project_json(attrs)
{:select, project_id} -> select_project_json(project_id)
:error -> Jason.encode!(%{status: "error", error: %{message: "Missing project name or project_id"}})
end
end
def command_json(payload) when is_map(payload) do
action = Map.get(payload, "action") || Map.get(payload, :action)
params = Map.get(payload, "params") || Map.get(payload, :params) || %{}
@@ -21,4 +40,72 @@ defmodule BDS.Desktop.ShellController do
defp normalize_error(error) when is_map(error), do: error
defp normalize_error(error), do: %{message: inspect(error)}
defp normalize_project_request(payload) do
cond do
present?(Map.get(payload, "name") || Map.get(payload, :name)) ->
{:create,
%{
name: String.trim(Map.get(payload, "name") || Map.get(payload, :name)),
data_path: blank_to_nil(Map.get(payload, "data_path") || Map.get(payload, :data_path))
}}
present?(Map.get(payload, "project_id") || Map.get(payload, :project_id)) ->
{:select, Map.get(payload, "project_id") || Map.get(payload, :project_id)}
true ->
:error
end
end
defp create_project_json(attrs) do
with {:ok, project} <- BDS.Projects.create_project(attrs),
{:ok, active_project} <- BDS.Projects.set_active_project(project.id) do
Jason.encode!(%{status: "ok", project: project_response(active_project), active_project_id: active_project.id})
else
{:error, error} -> Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
defp select_project_json(project_id) do
case BDS.Projects.set_active_project(project_id) do
{:ok, project} ->
Jason.encode!(%{status: "ok", project: project_response(project), active_project_id: project.id})
{:error, :not_found} ->
Jason.encode!(%{status: "error", error: %{message: "Project not found"}})
{:error, error} ->
Jason.encode!(%{status: "error", error: normalize_error(error)})
end
end
defp project_response(project) do
%{id: project.id, name: project.name, slug: project.slug, data_path: project.data_path, is_active: project.is_active}
end
defp default_project_snapshot do
%{
active_project_id: "default",
projects: [
%{
id: "default",
name: "My Blog",
slug: "my-blog",
data_path: nil,
is_active: true
}
]
}
end
defp present?(value) when is_binary(value), do: String.trim(value) != ""
defp present?(_value), do: false
defp blank_to_nil(value) when is_binary(value) do
trimmed = String.trim(value)
if trimmed == "", do: nil, else: trimmed
end
defp blank_to_nil(_value), do: nil
end

View File

@@ -21,6 +21,17 @@ defmodule BDS.Projects do
Repo.one(from project in Project, where: project.is_active == true, limit: 1)
end
def shell_snapshot do
_ = ensure_default_project()
projects = list_projects()
active_project = Enum.find(projects, & &1.is_active)
%{
active_project_id: active_project && active_project.id,
projects: Enum.map(projects, &project_summary/1)
}
end
def get_project(id), do: Repo.get(Project, id)
def get_project!(id), do: Repo.get!(Project, id)
@@ -150,6 +161,16 @@ defmodule BDS.Projects do
end
end
defp project_summary(%Project{} = project) do
%{
id: project.id,
name: project.name,
slug: project.slug,
data_path: project.data_path,
is_active: project.is_active
}
end
defp unique_slug(base_slug) do
normalized = if base_slug in [nil, ""], do: "project", else: base_slug

43
lib/bds/repo_bootstrap.ex Normal file
View File

@@ -0,0 +1,43 @@
defmodule BDS.RepoBootstrap do
@moduledoc false
use GenServer
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(opts) do
:ok = ensure_ready(opts)
{:ok, %{}}
end
def ensure_ready(opts \\ []) do
repo = Keyword.get(opts, :repo, BDS.Repo)
if Keyword.get(opts, :migrate?, true) do
:ok = ensure_schema(Keyword.put(opts, :repo, repo))
end
if repo == BDS.Repo do
case BDS.Projects.ensure_default_project() do
{:ok, _project} -> :ok
{:error, reason} -> raise "failed to ensure default project: #{inspect(reason)}"
end
else
:ok
end
end
def ensure_schema(opts \\ []) do
repo = Keyword.get(opts, :repo, BDS.Repo)
migrations_path = Keyword.get(opts, :migrations_path, migrations_path())
_versions = Ecto.Migrator.run(repo, migrations_path, :up, all: true)
:ok
end
def migrations_path do
Path.expand("../../priv/repo/migrations", __DIR__)
end
end

View File

@@ -2,6 +2,7 @@ defmodule BDS.UI.ShellPage do
@moduledoc false
alias BDS.I18n
alias BDS.Projects
alias BDS.UI.MenuBar
alias BDS.UI.Registry
alias BDS.UI.Session
@@ -57,7 +58,13 @@ defmodule BDS.UI.ShellPage do
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
i18n: %{
ui_language: ui_language,
supported_ui_languages: Enum.map(I18n.supported_languages(), &Map.take(&1, [:code, :flag]))
supported_ui_languages:
Enum.map(I18n.supported_languages(), fn language ->
%{
code: language.code,
flag: I18n.flag(language.code)
}
end)
},
registry: %{
sidebar_views: Enum.map(Registry.sidebar_views(), &encode_sidebar_view/1),
@@ -65,6 +72,7 @@ defmodule BDS.UI.ShellPage do
default_sidebar_view: Atom.to_string(Registry.default_sidebar_view())
},
menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1),
projects: project_snapshot(),
session: Session.serialize(workbench),
task_status: task_status,
content: %{
@@ -98,6 +106,32 @@ defmodule BDS.UI.ShellPage do
}
end
defp project_snapshot do
Projects.shell_snapshot()
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table: projects") do
reraise error, __STACKTRACE__
end
default_project_snapshot()
end
defp default_project_snapshot do
%{
active_project_id: "default",
projects: [
%{
id: "default",
name: "My Blog",
slug: "my-blog",
data_path: nil,
is_active: true
}
]
}
end
defp encode_editor_route(route) do
%{
id: Atom.to_string(route.id),

View File

@@ -5,7 +5,7 @@
--vscode-panel-background: #1e1e1e;
--vscode-titleBar-activeBackground: #252526;
--vscode-titleBar-activeForeground: #cccccc;
--vscode-statusBar-background: #181818;
--vscode-statusBar-background: #007acc;
--vscode-statusBar-foreground: #ffffff;
--vscode-tab-activeBackground: #1e1e1e;
--vscode-tab-inactiveBackground: #2d2d2d;
@@ -818,6 +818,128 @@ button {
opacity: 1;
}
.project-selector {
position: relative;
flex-shrink: 0;
}
.project-selector-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 22px;
background: transparent;
border: none;
color: var(--vscode-statusBar-foreground);
cursor: pointer;
font-size: 12px;
text-align: left;
}
.project-selector-trigger:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.project-selector-trigger:focus {
outline: none;
}
.project-icon,
.dropdown-arrow,
.project-check-icon {
flex-shrink: 0;
}
.project-name,
.project-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-name {
max-width: 180px;
}
.dropdown-arrow {
opacity: 0.6;
}
.project-dropdown {
position: absolute;
left: 0;
bottom: 100%;
min-width: 220px;
margin-bottom: 4px;
background-color: #252526;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.project-dropdown-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.project-list {
max-height: 200px;
overflow-y: auto;
}
.project-item {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
padding: 8px 12px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.project-item:hover,
.project-item.active {
background-color: var(--vscode-list-hoverBackground);
}
.project-item.active .project-check-icon {
color: #89d185;
}
.project-dropdown-footer {
padding: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.create-project-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
background-color: rgba(255, 255, 255, 0.12);
color: inherit;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.create-project-btn:hover {
background-color: rgba(255, 255, 255, 0.18);
}
.status-bar-language-select {
background: transparent;
border: none;

View File

@@ -13,6 +13,8 @@ const isMac = typeof navigator !== "undefined" && navigator.platform.toLowerCase
const state = {
session: hydrateSession(clone(bootstrap.session)),
status: clone(bootstrap.status),
projects: normalizeProjects(bootstrap.projects),
projectMenuOpen: false,
taskStatus: normalizeTaskStatus(bootstrap.task_status),
outputEntries: [],
gitLogEntries: [],
@@ -24,6 +26,7 @@ const state = {
bindNativeMenuBridge();
bindGlobalHotkeys();
scheduleTaskPolling();
void fetchProjects();
render();
function render() {
@@ -393,6 +396,7 @@ function renderStatusBar() {
root.querySelector(".status-bar").innerHTML = `
<div class="status-bar-left">
${renderProjectSelector()}
<button class="status-bar-item status-bar-task-button" data-command="open-tasks-panel" type="button">
<span>${escapeHtml(taskMessage)}</span>
${taskOverflow > 0 ? `<span class="status-bar-count">+${taskOverflow}</span>` : ""}
@@ -422,6 +426,10 @@ function bindEvents() {
root.querySelectorAll("button[data-command]").forEach((button) => {
button.onclick = () => {
const command = button.dataset.command;
if (command === "create-project") {
void createProject();
return;
}
if (command === "open-tasks-panel") {
openTasksPanel();
render();
@@ -435,6 +443,25 @@ function bindEvents() {
};
});
root.querySelectorAll("[data-project-menu-trigger]").forEach((button) => {
button.onclick = (event) => {
event.stopPropagation();
toggleProjectMenu();
};
});
root.querySelectorAll("[data-project-id]").forEach((button) => {
button.onclick = () => {
void selectProject(button.dataset.projectId);
};
});
root.querySelectorAll("[data-project-create]").forEach((button) => {
button.onclick = () => {
void createProject();
};
});
root.querySelectorAll("[data-command='set-ui-language']").forEach((select) => {
select.onchange = (event) => {
setUiLanguage(event.target.value);
@@ -512,6 +539,8 @@ function bindEvents() {
},
invert: true,
});
bindProjectMenuDismiss();
}
function bindNativeMenuBridge() {
@@ -606,6 +635,101 @@ async function fetchTaskStatus() {
}
}
async function fetchProjects() {
try {
const response = await fetch("/api/projects", {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!response.ok) {
return;
}
state.projects = normalizeProjects(await response.json());
if (!state.projects.active_project_id && state.projects.projects.length) {
state.projects.active_project_id = state.projects.projects[0].id;
}
render();
} catch (_error) {
// Keep the shell usable if project loading is temporarily unavailable.
}
}
async function createProject() {
const name = window.prompt("New project name", "New Project");
if (!name || !name.trim()) {
return;
}
closeProjectMenu();
try {
const response = await fetch("/api/projects", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ name: name.trim() }),
});
const payload = await response.json();
if (!response.ok || payload.status !== "ok") {
appendOutputEntry("Create Project", payload.error?.message || `Command failed with HTTP ${response.status}`);
setPanelTab("output");
render();
return;
}
await fetchProjects();
appendOutputEntry("Create Project", `Activated ${payload.project.name}`);
render();
} catch (error) {
appendOutputEntry("Create Project", error?.message || String(error));
setPanelTab("output");
render();
}
}
async function selectProject(projectId) {
if (!projectId || projectId === state.projects.active_project_id) {
closeProjectMenu();
return;
}
closeProjectMenu();
try {
const response = await fetch("/api/projects", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ project_id: projectId }),
});
const payload = await response.json();
if (!response.ok || payload.status !== "ok") {
appendOutputEntry("Select Project", payload.error?.message || `Command failed with HTTP ${response.status}`);
setPanelTab("output");
render();
return;
}
await fetchProjects();
appendOutputEntry("Select Project", `Activated ${payload.project.name}`);
render();
} catch (error) {
appendOutputEntry("Select Project", error?.message || String(error));
setPanelTab("output");
render();
}
}
function openTasksPanel() {
state.session.panel.visible = true;
state.session.panel.active_tab = "tasks";
@@ -1198,6 +1322,13 @@ function normalizeTaskStatus(taskStatus) {
};
}
function normalizeProjects(projectsPayload) {
return {
active_project_id: projectsPayload?.active_project_id || null,
projects: Array.isArray(projectsPayload?.projects) ? projectsPayload.projects : [],
};
}
function panelTabs() {
return ["tasks", state.session.panel.active_tab, "output", "git_log"].filter(uniqueValue);
}
@@ -1227,6 +1358,93 @@ function renderLanguageOptions() {
.join("");
}
function renderProjectSelector() {
const activeProject = currentProject();
return `
<div class="project-selector${state.projectMenuOpen ? " is-open" : ""}">
<button class="project-selector-trigger" data-project-menu-trigger type="button" title="Switch project">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="project-icon">
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"></path>
</svg>
<span class="project-name">${escapeHtml(activeProject?.name || "My Blog")}</span>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" class="dropdown-arrow">
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"></path>
</svg>
</button>
${state.projectMenuOpen ? renderProjectDropdown() : ""}
</div>
`;
}
function renderProjectDropdown() {
return `
<div class="project-dropdown">
<div class="project-dropdown-header">
<span>Projects</span>
</div>
<div class="project-list">
${state.projects.projects.map((project) => renderProjectItem(project)).join("")}
</div>
<div class="project-dropdown-footer">
<button class="create-project-btn" data-project-create type="button">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"></path>
</svg>
New Project
</button>
</div>
</div>
`;
}
function renderProjectItem(project) {
const active = project.id === state.projects.active_project_id;
return `
<button class="project-item ${active ? "active" : ""}" data-project-id="${escapeHtmlAttribute(project.id)}" type="button">
<span class="project-item-name">${escapeHtml(project.name)}</span>
${active ? `<span class="project-check-icon">✓</span>` : ""}
</button>
`;
}
function currentProject() {
return state.projects.projects.find((project) => project.id === state.projects.active_project_id) || state.projects.projects[0] || null;
}
function toggleProjectMenu() {
state.projectMenuOpen = !state.projectMenuOpen;
render();
}
function closeProjectMenu() {
if (!state.projectMenuOpen) {
return;
}
state.projectMenuOpen = false;
render();
}
function bindProjectMenuDismiss() {
if (window.__BDS_PROJECT_MENU_DISMISS_BOUND__) {
return;
}
window.__BDS_PROJECT_MENU_DISMISS_BOUND__ = true;
document.addEventListener("mousedown", (event) => {
if (!state.projectMenuOpen) {
return;
}
const selector = root.querySelector(".project-selector");
if (selector && !selector.contains(event.target)) {
closeProjectMenu();
}
});
}
function setUiLanguage(nextLanguage) {
state.uiLanguage = nextLanguage;
state.status.right.ui_language = nextLanguage;

View File

@@ -114,6 +114,64 @@ defmodule BDS.DesktopTest do
end)
end
test "desktop router exposes projects for shell project selection and creation" do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
BDS.Repo.delete_all(BDS.Projects.Project)
internal_projects_root = "/Users/gb/Projects/bDS2/priv/data/projects"
before_internal_dirs =
case File.ls(internal_projects_root) do
{:ok, entries} -> MapSet.new(entries)
{:error, :enoent} -> MapSet.new()
end
temp_dir = Path.join(System.tmp_dir!(), "bds-desktop-projects-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn ->
File.rm_rf(temp_dir)
end)
{:ok, project} = BDS.Projects.create_project(%{name: "Desktop Projects", data_path: temp_dir})
{:ok, _active} = BDS.Projects.set_active_project(project.id)
conn = conn(:get, "/api/projects?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Router.call(conn, BDS.Desktop.Router.init([]))
assert conn.status == 200
payload = Jason.decode!(conn.resp_body)
assert payload["active_project_id"] == project.id
assert Enum.any?(payload["projects"], &(&1["id"] == project.id and &1["name"] == "Desktop Projects"))
assert Enum.any?(payload["projects"], &(&1["id"] == "default" and &1["name"] == "My Blog"))
created_data_dir = Path.join(temp_dir, "created-from-shell")
create_conn =
conn(
:post,
"/api/projects?k=#{Desktop.Auth.login_key()}",
Jason.encode!(%{"name" => "Created From Shell", "data_path" => created_data_dir})
)
|> Plug.Conn.put_req_header("content-type", "application/json")
create_conn = BDS.Desktop.Router.call(create_conn, BDS.Desktop.Router.init([]))
assert create_conn.status == 200
created_payload = Jason.decode!(create_conn.resp_body)
assert created_payload["project"]["name"] == "Created From Shell"
assert created_payload["active_project_id"] == created_payload["project"]["id"]
assert created_payload["project"]["data_path"] == created_data_dir
after_internal_dirs =
case File.ls(internal_projects_root) do
{:ok, entries} -> MapSet.new(entries)
{:error, :enoent} -> MapSet.new()
end
assert after_internal_dirs == before_internal_dirs
end
test "desktop router executes shell commands through the JSON api" do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Preview))

View File

@@ -0,0 +1,70 @@
defmodule BDS.Repo.BootstrapTest do
use ExUnit.Case, async: false
alias BDS.Projects.Project
defmodule TempRepo do
use Ecto.Repo,
otp_app: :bds,
adapter: Ecto.Adapters.SQLite3
end
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
__MODULE__.RepoConfigBackup.put_env()
on_exit(fn ->
__MODULE__.RepoConfigBackup.restore_env()
end)
:ok
end
test "ensure_schema creates persistence tables in a blank sqlite database" do
temp_db = Path.join(System.tmp_dir!(), "bds-bootstrap-#{System.unique_integer([:positive])}.db")
Application.put_env(:bds, TempRepo,
database: temp_db,
pool_size: 1,
stacktrace: true,
show_sensitive_data_on_connection_error: true
)
start_supervised!(TempRepo)
on_exit(fn ->
File.rm_rf(temp_db)
end)
assert :ok = BDS.RepoBootstrap.ensure_schema(repo: TempRepo)
tables =
Ecto.Adapters.SQL.query!(TempRepo, "SELECT name FROM sqlite_master WHERE type = 'table'", []).rows
|> Enum.map(&hd/1)
assert "projects" in tables
assert "posts" in tables
assert "templates" in tables
end
test "ensure_ready seeds the default project in the app repo" do
BDS.Repo.delete_all(Project)
assert :ok = BDS.RepoBootstrap.ensure_ready(migrate?: false)
assert %Project{id: "default", name: "My Blog", is_active: true} = BDS.Projects.get_active_project()
end
defmodule RepoConfigBackup do
def put_env do
Process.put({__MODULE__, :temp_repo_config}, Application.get_env(:bds, TempRepo))
end
def restore_env do
case Process.get({__MODULE__, :temp_repo_config}) do
nil -> Application.delete_env(:bds, TempRepo)
config -> Application.put_env(:bds, TempRepo, config)
end
end
end
end

View File

@@ -109,6 +109,10 @@ defmodule BDS.UI.ShellTest do
assert html =~ ~s(src="/assets/app.js")
assert html =~ ~s(href="/assets/app.css")
assert html =~ ~s("task_status")
assert html =~ ~s("flag":"🇩🇪")
assert html =~ ~s("projects")
assert html =~ ~s("id":"default")
assert html =~ ~s("name":"My Blog")
end
test "static shell bundle exists for direct browser inspection" do
@@ -144,7 +148,7 @@ defmodule BDS.UI.ShellTest do
assert js =~ "window-titlebar-menu-bar is-hidden"
assert css =~ ".window-titlebar-menu-bar.is-hidden"
assert css =~ "--vscode-statusBar-background: #181818"
assert css =~ "--vscode-statusBar-background: #007acc"
assert css =~ ".status-bar-left,"
assert css =~ "gap: 4px"
assert css =~ "padding: 0 8px"
@@ -155,6 +159,7 @@ defmodule BDS.UI.ShellTest do
assert css =~ ".status-bar-item.offline-badge"
assert js =~ "renderLanguageOptions"
assert js =~ "language.flag || language.code.toUpperCase()"
assert js =~ "status-bar-language-select"
assert js =~ "setUiLanguage"
end
@@ -163,6 +168,7 @@ defmodule BDS.UI.ShellTest do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "/api/tasks"
assert js =~ "/api/projects"
assert js =~ "/api/commands"
assert js =~ "fetchTaskStatus"
assert js =~ "executeBackendShellCommand"
@@ -179,6 +185,24 @@ defmodule BDS.UI.ShellTest do
assert js =~ "data-panel-tab=\"git_log\""
end
test "static shell bundle renders a left-side project field with selection and create affordances" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "project-selector-trigger"
assert js =~ "project-dropdown"
assert js =~ "create-project-btn"
assert js =~ "fetchProjects"
assert js =~ "createProject"
assert js =~ "selectProject"
assert js =~ "toggleProjectMenu"
assert js =~ "closeProjectMenu"
assert css =~ ".project-selector-trigger"
assert css =~ ".project-dropdown"
assert css =~ ".create-project-btn"
end
test "static shell bundle binds base shell hotkeys and menu actions to existing shell functionality" do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")