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 def start(_type, _args) do
children = [ children = [
BDS.Repo, BDS.Repo,
BDS.RepoBootstrap,
BDS.Tasks, BDS.Tasks,
BDS.Preview, BDS.Preview,
BDS.Publishing, BDS.Publishing,

View File

@@ -36,6 +36,21 @@ defmodule BDS.Desktop.Router do
|> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.task_status_json()) |> Plug.Conn.send_resp(200, BDS.Desktop.ShellController.task_status_json())
end 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 post "/api/commands" do
{:ok, body, conn} = Plug.Conn.read_body(conn) {:ok, body, conn} = Plug.Conn.read_body(conn)
payload = if body == "", do: %{}, else: Jason.decode!(body) 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()) Jason.encode!(BDS.Tasks.status_snapshot())
end 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 def command_json(payload) when is_map(payload) do
action = Map.get(payload, "action") || Map.get(payload, :action) action = Map.get(payload, "action") || Map.get(payload, :action)
params = Map.get(payload, "params") || Map.get(payload, :params) || %{} 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) when is_map(error), do: error
defp normalize_error(error), do: %{message: inspect(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 end

View File

@@ -21,6 +21,17 @@ defmodule BDS.Projects do
Repo.one(from project in Project, where: project.is_active == true, limit: 1) Repo.one(from project in Project, where: project.is_active == true, limit: 1)
end 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)
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
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 defp unique_slug(base_slug) do
normalized = if base_slug in [nil, ""], do: "project", else: base_slug 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 @moduledoc false
alias BDS.I18n alias BDS.I18n
alias BDS.Projects
alias BDS.UI.MenuBar alias BDS.UI.MenuBar
alias BDS.UI.Registry alias BDS.UI.Registry
alias BDS.UI.Session alias BDS.UI.Session
@@ -57,7 +58,13 @@ defmodule BDS.UI.ShellPage do
title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server", title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server",
i18n: %{ i18n: %{
ui_language: ui_language, 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: %{ registry: %{
sidebar_views: Enum.map(Registry.sidebar_views(), &encode_sidebar_view/1), 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()) default_sidebar_view: Atom.to_string(Registry.default_sidebar_view())
}, },
menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1), menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1),
projects: project_snapshot(),
session: Session.serialize(workbench), session: Session.serialize(workbench),
task_status: task_status, task_status: task_status,
content: %{ content: %{
@@ -98,6 +106,32 @@ defmodule BDS.UI.ShellPage do
} }
end 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 defp encode_editor_route(route) do
%{ %{
id: Atom.to_string(route.id), id: Atom.to_string(route.id),

View File

@@ -5,7 +5,7 @@
--vscode-panel-background: #1e1e1e; --vscode-panel-background: #1e1e1e;
--vscode-titleBar-activeBackground: #252526; --vscode-titleBar-activeBackground: #252526;
--vscode-titleBar-activeForeground: #cccccc; --vscode-titleBar-activeForeground: #cccccc;
--vscode-statusBar-background: #181818; --vscode-statusBar-background: #007acc;
--vscode-statusBar-foreground: #ffffff; --vscode-statusBar-foreground: #ffffff;
--vscode-tab-activeBackground: #1e1e1e; --vscode-tab-activeBackground: #1e1e1e;
--vscode-tab-inactiveBackground: #2d2d2d; --vscode-tab-inactiveBackground: #2d2d2d;
@@ -818,6 +818,128 @@ button {
opacity: 1; 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 { .status-bar-language-select {
background: transparent; background: transparent;
border: none; border: none;

View File

@@ -13,6 +13,8 @@ const isMac = typeof navigator !== "undefined" && navigator.platform.toLowerCase
const state = { const state = {
session: hydrateSession(clone(bootstrap.session)), session: hydrateSession(clone(bootstrap.session)),
status: clone(bootstrap.status), status: clone(bootstrap.status),
projects: normalizeProjects(bootstrap.projects),
projectMenuOpen: false,
taskStatus: normalizeTaskStatus(bootstrap.task_status), taskStatus: normalizeTaskStatus(bootstrap.task_status),
outputEntries: [], outputEntries: [],
gitLogEntries: [], gitLogEntries: [],
@@ -24,6 +26,7 @@ const state = {
bindNativeMenuBridge(); bindNativeMenuBridge();
bindGlobalHotkeys(); bindGlobalHotkeys();
scheduleTaskPolling(); scheduleTaskPolling();
void fetchProjects();
render(); render();
function render() { function render() {
@@ -393,6 +396,7 @@ function renderStatusBar() {
root.querySelector(".status-bar").innerHTML = ` root.querySelector(".status-bar").innerHTML = `
<div class="status-bar-left"> <div class="status-bar-left">
${renderProjectSelector()}
<button class="status-bar-item status-bar-task-button" data-command="open-tasks-panel" type="button"> <button class="status-bar-item status-bar-task-button" data-command="open-tasks-panel" type="button">
<span>${escapeHtml(taskMessage)}</span> <span>${escapeHtml(taskMessage)}</span>
${taskOverflow > 0 ? `<span class="status-bar-count">+${taskOverflow}</span>` : ""} ${taskOverflow > 0 ? `<span class="status-bar-count">+${taskOverflow}</span>` : ""}
@@ -422,6 +426,10 @@ function bindEvents() {
root.querySelectorAll("button[data-command]").forEach((button) => { root.querySelectorAll("button[data-command]").forEach((button) => {
button.onclick = () => { button.onclick = () => {
const command = button.dataset.command; const command = button.dataset.command;
if (command === "create-project") {
void createProject();
return;
}
if (command === "open-tasks-panel") { if (command === "open-tasks-panel") {
openTasksPanel(); openTasksPanel();
render(); 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) => { root.querySelectorAll("[data-command='set-ui-language']").forEach((select) => {
select.onchange = (event) => { select.onchange = (event) => {
setUiLanguage(event.target.value); setUiLanguage(event.target.value);
@@ -512,6 +539,8 @@ function bindEvents() {
}, },
invert: true, invert: true,
}); });
bindProjectMenuDismiss();
} }
function bindNativeMenuBridge() { 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() { function openTasksPanel() {
state.session.panel.visible = true; state.session.panel.visible = true;
state.session.panel.active_tab = "tasks"; 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() { function panelTabs() {
return ["tasks", state.session.panel.active_tab, "output", "git_log"].filter(uniqueValue); return ["tasks", state.session.panel.active_tab, "output", "git_log"].filter(uniqueValue);
} }
@@ -1227,6 +1358,93 @@ function renderLanguageOptions() {
.join(""); .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) { function setUiLanguage(nextLanguage) {
state.uiLanguage = nextLanguage; state.uiLanguage = nextLanguage;
state.status.right.ui_language = nextLanguage; state.status.right.ui_language = nextLanguage;

View File

@@ -114,6 +114,64 @@ defmodule BDS.DesktopTest do
end) end)
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 test "desktop router executes shell commands through the JSON api" do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Preview)) :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(src="/assets/app.js")
assert html =~ ~s(href="/assets/app.css") assert html =~ ~s(href="/assets/app.css")
assert html =~ ~s("task_status") 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 end
test "static shell bundle exists for direct browser inspection" do 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 js =~ "window-titlebar-menu-bar is-hidden"
assert css =~ ".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 =~ ".status-bar-left,"
assert css =~ "gap: 4px" assert css =~ "gap: 4px"
assert css =~ "padding: 0 8px" assert css =~ "padding: 0 8px"
@@ -155,6 +159,7 @@ defmodule BDS.UI.ShellTest do
assert css =~ ".status-bar-item.offline-badge" assert css =~ ".status-bar-item.offline-badge"
assert js =~ "renderLanguageOptions" assert js =~ "renderLanguageOptions"
assert js =~ "language.flag || language.code.toUpperCase()"
assert js =~ "status-bar-language-select" assert js =~ "status-bar-language-select"
assert js =~ "setUiLanguage" assert js =~ "setUiLanguage"
end end
@@ -163,6 +168,7 @@ defmodule BDS.UI.ShellTest do
js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js") js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")
assert js =~ "/api/tasks" assert js =~ "/api/tasks"
assert js =~ "/api/projects"
assert js =~ "/api/commands" assert js =~ "/api/commands"
assert js =~ "fetchTaskStatus" assert js =~ "fetchTaskStatus"
assert js =~ "executeBackendShellCommand" assert js =~ "executeBackendShellCommand"
@@ -179,6 +185,24 @@ defmodule BDS.UI.ShellTest do
assert js =~ "data-panel-tab=\"git_log\"" assert js =~ "data-panel-tab=\"git_log\""
end 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 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") js = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.js")