diff --git a/lib/bds/application.ex b/lib/bds/application.ex index 7ee52c2..762e9f0 100644 --- a/lib/bds/application.ex +++ b/lib/bds/application.ex @@ -25,6 +25,7 @@ defmodule BDS.Application do def start(_type, _args) do children = [ BDS.Repo, + BDS.RepoBootstrap, BDS.Tasks, BDS.Preview, BDS.Publishing, diff --git a/lib/bds/desktop/router.ex b/lib/bds/desktop/router.ex index 0843227..918008e 100644 --- a/lib/bds/desktop/router.ex +++ b/lib/bds/desktop/router.ex @@ -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) diff --git a/lib/bds/desktop/shell_controller.ex b/lib/bds/desktop/shell_controller.ex index 7ccd67f..0a00d65 100644 --- a/lib/bds/desktop/shell_controller.ex +++ b/lib/bds/desktop/shell_controller.ex @@ -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 diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex index 2c688b6..3e238b8 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -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 diff --git a/lib/bds/repo_bootstrap.ex b/lib/bds/repo_bootstrap.ex new file mode 100644 index 0000000..dedd6fb --- /dev/null +++ b/lib/bds/repo_bootstrap.ex @@ -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 diff --git a/lib/bds/ui/shell_page.ex b/lib/bds/ui/shell_page.ex index e43b6df..af942a7 100644 --- a/lib/bds/ui/shell_page.ex +++ b/lib/bds/ui/shell_page.ex @@ -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), diff --git a/priv/ui/app.css b/priv/ui/app.css index 7851c69..5b129d5 100644 --- a/priv/ui/app.css +++ b/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; diff --git a/priv/ui/app.js b/priv/ui/app.js index d3ea3bd..83c4c12 100644 --- a/priv/ui/app.js +++ b/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 = `
+ ${renderProjectSelector()} + ${state.projectMenuOpen ? renderProjectDropdown() : ""} +
+ `; +} + +function renderProjectDropdown() { + return ` +
+
+ Projects +
+
+ ${state.projects.projects.map((project) => renderProjectItem(project)).join("")} +
+ +
+ `; +} + +function renderProjectItem(project) { + const active = project.id === state.projects.active_project_id; + + return ` + + `; +} + +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; diff --git a/test/bds/desktop_test.exs b/test/bds/desktop_test.exs index ebae4f2..3116278 100644 --- a/test/bds/desktop_test.exs +++ b/test/bds/desktop_test.exs @@ -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)) diff --git a/test/bds/repo/bootstrap_test.exs b/test/bds/repo/bootstrap_test.exs new file mode 100644 index 0000000..8487661 --- /dev/null +++ b/test/bds/repo/bootstrap_test.exs @@ -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 diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index ce5b7aa..7336271 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -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")