feat: more UI cleanup
This commit is contained in:
@@ -25,6 +25,7 @@ defmodule BDS.Application do
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
BDS.Repo,
|
||||
BDS.RepoBootstrap,
|
||||
BDS.Tasks,
|
||||
BDS.Preview,
|
||||
BDS.Publishing,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
43
lib/bds/repo_bootstrap.ex
Normal 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
|
||||
@@ -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),
|
||||
|
||||
124
priv/ui/app.css
124
priv/ui/app.css
@@ -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;
|
||||
|
||||
218
priv/ui/app.js
218
priv/ui/app.js
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
70
test/bds/repo/bootstrap_test.exs
Normal file
70
test/bds/repo/bootstrap_test.exs
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user