feat: more UI cleanup
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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
|
@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),
|
||||||
|
|||||||
124
priv/ui/app.css
124
priv/ui/app.css
@@ -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;
|
||||||
|
|||||||
218
priv/ui/app.js
218
priv/ui/app.js
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
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(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")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user