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()}