Files
Gitpub-Desktop/frontend/js/app.js
T
andrew ce7f83734a Add repo filters, UI tabs and dev docs
Enhance UI and dev workflow: add main/right tabbed panels (Repositories/Local and Clone/Settings/Servers), repository owner filter pills (All/Personal/Organizations), org-grouped listing, repo cards, git output and empty-state styling. Introduce new state vars (activeMainTab, activeRightTab, repoOwnerFilter, currentUserLogin) and update event bindings to toggle tabs/filters and focus appropriate panels on actions. Add fetchCurrentUser to gitea-api and use it when loading repositories to distinguish personal vs organization repos (falls back to mock user on error). Update README with development/prerequisites and build instructions and rename npm script "tauri:dev" -> "dev" for running the Tauri dev process.
2026-05-09 18:12:36 +12:00

611 lines
24 KiB
JavaScript

import { fetchCurrentUser, fetchRepositories } from "./gitea-api.js";
import { getActiveServer, getState, setSettings, updateSettings, addRecentRepo } from "./state.js";
import {
runGitBranch,
runGitClone,
runGitPull,
runGitPush,
runGitStatus,
testGiteaConnection,
} from "./tauri-api.js";
const appRoot = document.getElementById("app");
const mockRepos = [
{ id: 1, full_name: "alice/portfolio", private: false, clone_url: "https://example.com/portfolio.git", updated_at: "today", owner: { login: "alice", type: "User" } },
{ id: 2, full_name: "alice/dotfiles", private: true, clone_url: "https://example.com/dotfiles.git", updated_at: "yesterday", owner: { login: "alice", type: "User" } },
{ id: 3, full_name: "acme-corp/client-ui", private: false, clone_url: "https://example.com/client-ui.git", updated_at: "today", owner: { login: "acme-corp", type: "Organization" } },
{ id: 4, full_name: "acme-corp/api-server", private: true, clone_url: "https://example.com/api-server.git", updated_at: "3 days ago", owner: { login: "acme-corp", type: "Organization" } },
{ id: 5, full_name: "oss-collective/toolkit", private: false, clone_url: "https://example.com/toolkit.git", updated_at: "last week", owner: { login: "oss-collective", type: "Organization" } },
];
let repositories = [...mockRepos];
let currentUserLogin = ""; // authenticated user's login name
let serverTestResult = "";
let settingsNotice = "";
let gitOutput = "";
let activeRightTab = "clone"; // "clone" | "settings" | "servers"
let activeMainTab = "repos"; // "repos" | "local"
let repoOwnerFilter = "all"; // "all" | "personal" | "orgs"
function uid() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function escapeHtml(value = "") {
return value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function serverFormTemplate(server = null) {
// Reused in first-launch setup and in Settings server management.
const defaults = {
id: "",
displayName: "",
serverUrl: "",
authMethod: "token",
token: "",
username: "",
password: "",
};
const config = { ...defaults, ...(server || {}) };
return `
<div class="stack panel" id="server-form-card">
<h3 class="title">${server ? "Edit Server" : "Add Server"}</h3>
<input type="hidden" name="id" value="${escapeHtml(config.id)}" />
<div>
<div class="label">Display name</div>
<input name="displayName" value="${escapeHtml(config.displayName)}" placeholder="Gitpub Main" />
</div>
<div>
<div class="label">Gitea server URL</div>
<input name="serverUrl" value="${escapeHtml(config.serverUrl)}" placeholder="https://git.example.com" />
</div>
<div>
<div class="label">Auth method</div>
<select name="authMethod">
<option value="token" ${config.authMethod === "token" ? "selected" : ""}>Access Token</option>
<option value="password" ${config.authMethod === "password" ? "selected" : ""}>Username / Password</option>
</select>
</div>
<div>
<div class="label">Access token</div>
<input name="token" value="${escapeHtml(config.token)}" placeholder="gitea token" />
</div>
<div class="row">
<div style="flex: 1">
<div class="label">Username</div>
<input name="username" value="${escapeHtml(config.username)}" placeholder="username" />
</div>
<div style="flex: 1">
<div class="label">Password</div>
<input type="password" name="password" value="${escapeHtml(config.password)}" placeholder="password" />
</div>
</div>
<div class="row wrap">
<button id="test-server-btn" type="button">Test connection</button>
<button id="save-server-btn" class="primary" type="button">Save server</button>
</div>
<div class="muted">${escapeHtml(serverTestResult || settingsNotice)}</div>
</div>
`;
}
function welcomeView() {
appRoot.innerHTML = `
<div class="welcome-wrap">
<div class="welcome-card stack">
<div class="panel stack">
<h1 class="title">Welcome to Gitpub Desktop</h1>
<p class="subtitle">
Connect your first Gitea backend. Gitpub Desktop works with any compatible Gitea server.
</p>
${serverFormTemplate(null)}
</div>
</div>
</div>
`;
bindServerFormEvents();
}
function isOrgRepo(repo) {
const ownerLogin = repo.owner?.login || repo.full_name.split("/")[0];
// When we know the authenticated user, compare against owner login.
// Fall back to owner.type for cases where user info wasn't fetched.
if (currentUserLogin) {
return ownerLogin.toLowerCase() !== currentUserLogin.toLowerCase();
}
return repo.owner?.type === "Organization" || repo.owner?.type === "organization";
}
function filteredRepositories() {
const searchTerm = getState().repoSearch.trim().toLowerCase();
let result = repositories;
if (repoOwnerFilter === "personal") {
result = result.filter((repo) => !isOrgRepo(repo));
} else if (repoOwnerFilter === "orgs") {
result = result.filter((repo) => isOrgRepo(repo));
}
if (searchTerm) {
result = result.filter((repo) => repo.full_name.toLowerCase().includes(searchTerm));
}
return result;
}
function groupedByOrg(repos) {
const groups = {};
for (const repo of repos) {
const org = repo.owner?.login || "Unknown";
if (!groups[org]) groups[org] = [];
groups[org].push(repo);
}
return groups;
}
function repoCardTemplate(repo) {
return `
<article class="repo-card stack">
<div><strong>${escapeHtml(repo.full_name)}</strong></div>
<div class="muted">${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")}</div>
<div class="row">
<button class="clone-repo-btn" data-clone-url="${escapeHtml(repo.clone_url || "")}" type="button">Clone</button>
<button class="open-repo-btn" data-repo-name="${escapeHtml(repo.full_name)}" type="button">Open</button>
</div>
</article>
`;
}
function dashboardView() {
const state = getState();
const activeServer = getActiveServer();
const visibleRepos = filteredRepositories();
appRoot.innerHTML = `
<div class="layout">
<aside class="sidebar stack">
<div class="sidebar-brand">
<h2 class="title">Gitpub Desktop</h2>
</div>
<div class="server-chip">
<div class="label">Active server</div>
<div>${escapeHtml(activeServer?.displayName || "No server selected")}</div>
<div class="muted">${escapeHtml(activeServer?.serverUrl || "Add a server to connect")}</div>
</div>
<div class="sidebar-nav">
<button id="refresh-repos-btn" class="sidebar-btn" type="button">Refresh</button>
<button id="open-settings-btn" class="sidebar-btn" type="button">Servers</button>
</div>
<div class="sidebar-recents">
<div class="label">Recent repositories</div>
<ul class="list">
${state.settings.recentRepositories.length
? state.settings.recentRepositories
.map((path) => `<li class="recent-item" title="${escapeHtml(path)}">${escapeHtml(path.split(/[/\\]/).filter(Boolean).pop() || path)}</li>`)
.join("")
: "<li class='muted'>No recent repositories.</li>"}
</ul>
</div>
</aside>
<main class="main stack">
<div class="tabs main-tabs">
<button class="tab-btn ${activeMainTab === "repos" ? "active" : ""}" data-main-tab="repos">Repositories</button>
<button class="tab-btn ${activeMainTab === "local" ? "active" : ""}" data-main-tab="local">Local Repo</button>
${activeMainTab === "repos"
? `<span class="tab-spacer"></span><input id="repo-search-input" class="tab-search" placeholder="Search…" value="${escapeHtml(state.repoSearch)}" />`
: ""}
</div>
${activeMainTab === "repos" ? `
<section class="panel stack">
<div class="section-header">
<h3 class="title">Repositories</h3>
<span class="muted">${visibleRepos.length} shown</span>
</div>
<div class="repo-filter-pills">
<button class="pill-btn ${repoOwnerFilter === "all" ? "active" : ""}" data-owner-filter="all">All</button>
<button class="pill-btn ${repoOwnerFilter === "personal" ? "active" : ""}" data-owner-filter="personal">Personal</button>
<button class="pill-btn ${repoOwnerFilter === "orgs" ? "active" : ""}" data-owner-filter="orgs">Organizations</button>
</div>
${repoOwnerFilter === "orgs"
? (() => {
const groups = groupedByOrg(visibleRepos);
const orgNames = Object.keys(groups).sort();
if (!orgNames.length) {
return `<div class="empty-state"><div>No organisation repositories found</div><div class="muted">Try refreshing or check your server connection.</div></div>`;
}
return orgNames.map((org) => `
<div class="org-group stack">
<div class="org-group-header">
<span class="org-badge">${escapeHtml(org)}</span>
<span class="muted">${groups[org].length} repo${groups[org].length !== 1 ? "s" : ""}</span>
</div>
<div class="repo-grid">
${groups[org].map((repo) => repoCardTemplate(repo)).join("")}
</div>
</div>
`).join("");
})()
: `<div class="repo-grid">
${visibleRepos.length
? visibleRepos.map((repo) => repoCardTemplate(repo)).join("")
: `<div class="empty-state">
<div>No repositories found</div>
<div class="muted">Try refreshing or check your server connection.</div>
</div>`}
</div>`}
</section>
` : `
<section class="panel stack">
<h3 class="title">Local Repository</h3>
<div class="row">
<input id="local-repo-path-input" placeholder="Path to local repository…" value="${escapeHtml(state.localRepoPathInput)}" />
<button id="open-local-repo-btn" type="button">Open</button>
</div>
${state.selectedRepoName
? `<div class="selected-repo"><span class="label">Active: </span>${escapeHtml(state.selectedRepoName)}</div>`
: ""}
<div class="row wrap">
<button id="git-status-btn" type="button">Status</button>
<button id="git-branch-btn" type="button">Branch</button>
<button id="git-pull-btn" type="button">Pull</button>
<button id="git-push-btn" type="button">Push</button>
</div>
<div>
<div class="label">Commit message</div>
<textarea id="commit-message-input" placeholder="Write commit message…">${escapeHtml(state.commitMessage)}</textarea>
<div class="muted commit-note">Commit support coming in the next milestone.</div>
</div>
${gitOutput
? `<pre class="git-output">${escapeHtml(gitOutput)}</pre>`
: `<p class="muted git-output-placeholder">Run a git command to see output here.</p>`}
</section>
`}
</main>
<aside class="rightbar stack">
<div class="tabs right-tabs">
<button class="tab-btn ${activeRightTab === "clone" ? "active" : ""}" data-right-tab="clone">Clone</button>
<button class="tab-btn ${activeRightTab === "settings" ? "active" : ""}" data-right-tab="settings">Settings</button>
<button class="tab-btn ${activeRightTab === "servers" ? "active" : ""}" data-right-tab="servers">Servers</button>
</div>
${activeRightTab === "clone" ? `
<section class="panel stack">
<div>
<div class="label">Repository URL</div>
<input id="clone-url-input" placeholder="https://git.example.com/org/repo.git" value="${escapeHtml(state.cloneUrlInput)}" />
</div>
<div>
<div class="label">Destination path</div>
<input id="clone-destination-input" placeholder="/Users/me/code/repo" value="${escapeHtml(state.cloneDestinationInput || state.settings.defaultCloneDirectory)}" />
</div>
<button id="clone-btn" class="primary" type="button">Clone</button>
<span class="muted">Uses system Git from settings.</span>
</section>
` : ""}
${activeRightTab === "settings" ? `
<section class="panel stack">
<div>
<div class="label">Theme</div>
<select id="theme-select">
<option value="dark" ${state.settings.theme === "dark" ? "selected" : ""}>Dark (default)</option>
</select>
</div>
<div>
<div class="label">Git executable path</div>
<input id="git-path-input" value="${escapeHtml(state.settings.gitExecutablePath)}" placeholder="git or /usr/bin/git" />
</div>
<div>
<div class="label">Default clone directory</div>
<input id="default-clone-dir-input" value="${escapeHtml(state.settings.defaultCloneDirectory)}" placeholder="/Users/me/code" />
</div>
<button id="save-basic-settings-btn" class="primary" type="button">Save settings</button>
${settingsNotice ? `<div class="muted">${escapeHtml(settingsNotice)}</div>` : ""}
</section>
` : ""}
${activeRightTab === "servers" ? `
<section class="panel stack">
${state.settings.servers
.map((server) => `
<div class="server-chip stack">
<strong>${escapeHtml(server.displayName)}</strong>
<span class="muted">${escapeHtml(server.serverUrl)}</span>
<div class="row wrap">
<button class="set-default-server-btn" data-id="${server.id}" type="button">Set default</button>
<button class="edit-server-btn" data-id="${server.id}" type="button">Edit</button>
<button class="delete-server-btn danger" data-id="${server.id}" type="button">Remove</button>
</div>
</div>
`).join("")}
<button id="add-server-btn" type="button">Add new server</button>
<div id="server-form-slot" class="stack"></div>
${settingsNotice ? `<div class="muted">${escapeHtml(settingsNotice)}</div>` : ""}
</section>
` : ""}
</aside>
</div>
`;
bindDashboardEvents();
}
function bindServerFormEvents(existingServer = null) {
const testButton = document.getElementById("test-server-btn");
const saveButton = document.getElementById("save-server-btn");
const formCard = document.getElementById("server-form-card");
if (!testButton || !saveButton || !formCard) return;
const collectPayload = () => {
const get = (name) => formCard.querySelector(`[name="${name}"]`)?.value?.trim() || "";
return {
id: get("id"),
displayName: get("displayName"),
serverUrl: get("serverUrl"),
authMethod: get("authMethod") || "token",
token: get("token"),
username: get("username"),
password: get("password"),
};
};
testButton.addEventListener("click", async () => {
const payload = collectPayload();
try {
const result = await testGiteaConnection(payload);
serverTestResult = `${result.ok ? "Connected" : "Failed"}: ${result.message} (${result.apiBaseUrl})`;
render();
if (existingServer) {
openServerForm(existingServer.id);
}
} catch (error) {
serverTestResult = `Connection test failed: ${error.message}`;
render();
if (existingServer) {
openServerForm(existingServer.id);
}
}
});
saveButton.addEventListener("click", () => {
const payload = collectPayload();
if (!payload.displayName || !payload.serverUrl) {
settingsNotice = "Display name and server URL are required.";
render();
if (existingServer) openServerForm(existingServer.id);
return;
}
const state = getState();
let nextServers = [...state.settings.servers];
const nextId = payload.id || uid();
const serverData = { ...payload, id: nextId };
const existingIndex = nextServers.findIndex((item) => item.id === nextId);
if (existingIndex >= 0) {
nextServers[existingIndex] = serverData;
settingsNotice = `Updated server ${payload.displayName}.`;
} else {
nextServers.push(serverData);
settingsNotice = `Added server ${payload.displayName}.`;
}
const activeServerId = state.settings.activeServerId || nextId;
setSettings({ ...state.settings, servers: nextServers, activeServerId });
serverTestResult = "";
render();
});
}
function openServerForm(serverId = null) {
const slot = document.getElementById("server-form-slot");
if (!slot) return;
const server = getState().settings.servers.find((item) => item.id === serverId) || null;
slot.innerHTML = serverFormTemplate(server);
bindServerFormEvents(server);
}
async function loadRepositories() {
const activeServer = getActiveServer();
if (!activeServer) {
repositories = [...mockRepos];
currentUserLogin = "alice"; // mock user matches mock personal repos
return;
}
try {
const [user, repos] = await Promise.all([
fetchCurrentUser(activeServer),
fetchRepositories(activeServer),
]);
currentUserLogin = user.login || "";
repositories = repos;
} catch (error) {
settingsNotice = `Using mock repositories. API fetch failed: ${error.message}`;
currentUserLogin = "alice";
repositories = [...mockRepos];
}
}
async function runRepoCommand(actionName, runner) {
const state = getState();
if (!state.selectedRepoPath) {
gitOutput = "Select or enter a local repository path first.";
render();
return;
}
try {
const result = await runner();
gitOutput = `${actionName}: ${result.command}\n\n${result.stdout || "(no stdout)"}\n${result.stderr ? `\n${result.stderr}` : ""}`;
} catch (error) {
gitOutput = `${actionName} failed: ${error.message}`;
}
render();
}
function bindDashboardEvents() {
const state = getState();
document.getElementById("repo-search-input")?.addEventListener("input", (event) => {
state.repoSearch = event.target.value;
render();
});
document.getElementById("refresh-repos-btn")?.addEventListener("click", async () => {
await loadRepositories();
render();
});
document.getElementById("open-settings-btn")?.addEventListener("click", () => {
activeRightTab = "servers";
render();
});
document.querySelectorAll("[data-right-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
activeRightTab = btn.dataset.rightTab;
render();
});
});
document.querySelectorAll("[data-main-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
activeMainTab = btn.dataset.mainTab;
render();
});
});
document.querySelectorAll("[data-owner-filter]").forEach((btn) => {
btn.addEventListener("click", () => {
repoOwnerFilter = btn.dataset.ownerFilter;
render();
});
});
document.querySelectorAll(".open-repo-btn").forEach((button) => {
button.addEventListener("click", () => {
state.selectedRepoName = button.dataset.repoName || "Repository";
activeMainTab = "local";
render();
});
});
document.querySelectorAll(".clone-repo-btn").forEach((button) => {
button.addEventListener("click", () => {
state.cloneUrlInput = button.dataset.cloneUrl || "";
activeRightTab = "clone";
render();
});
});
document.getElementById("open-local-repo-btn")?.addEventListener("click", () => {
// MVP local detection is user-driven path entry; folder picker can be added next.
const value = document.getElementById("local-repo-path-input")?.value?.trim() || "";
state.localRepoPathInput = value;
state.selectedRepoPath = value;
state.selectedRepoName = value.split("/").filter(Boolean).pop() || value;
addRecentRepo(value);
render();
});
document.getElementById("clone-btn")?.addEventListener("click", async () => {
state.cloneUrlInput = document.getElementById("clone-url-input")?.value?.trim() || "";
state.cloneDestinationInput = document.getElementById("clone-destination-input")?.value?.trim() || "";
if (!state.cloneUrlInput || !state.cloneDestinationInput) {
gitOutput = "Clone URL and destination path are required.";
render();
return;
}
try {
const result = await runGitClone(
state.cloneUrlInput,
state.cloneDestinationInput,
state.settings.gitExecutablePath
);
gitOutput = `${result.command}\n\n${result.stdout || "Clone completed."}\n${result.stderr || ""}`;
addRecentRepo(state.cloneDestinationInput);
} catch (error) {
gitOutput = `Clone failed: ${error.message}`;
}
render();
});
document.getElementById("git-status-btn")?.addEventListener("click", () => {
runRepoCommand("Status", () => runGitStatus(state.selectedRepoPath, state.settings.gitExecutablePath));
});
document.getElementById("git-branch-btn")?.addEventListener("click", () => {
runRepoCommand("Branch", () => runGitBranch(state.selectedRepoPath, state.settings.gitExecutablePath));
});
document.getElementById("git-pull-btn")?.addEventListener("click", () => {
runRepoCommand("Pull", () => runGitPull(state.selectedRepoPath, state.settings.gitExecutablePath));
});
document.getElementById("git-push-btn")?.addEventListener("click", () => {
runRepoCommand("Push", () => runGitPush(state.selectedRepoPath, state.settings.gitExecutablePath));
});
document.getElementById("save-basic-settings-btn")?.addEventListener("click", () => {
updateSettings({
theme: document.getElementById("theme-select")?.value || "dark",
gitExecutablePath: document.getElementById("git-path-input")?.value?.trim() || "",
defaultCloneDirectory: document.getElementById("default-clone-dir-input")?.value?.trim() || "",
});
settingsNotice = "Saved basic settings.";
render();
});
document.getElementById("add-server-btn")?.addEventListener("click", () => openServerForm());
document.querySelectorAll(".set-default-server-btn").forEach((button) => {
button.addEventListener("click", async () => {
updateSettings({ activeServerId: button.dataset.id });
settingsNotice = "Default server updated.";
await loadRepositories();
render();
});
});
document.querySelectorAll(".delete-server-btn").forEach((button) => {
button.addEventListener("click", () => {
const nextServers = getState().settings.servers.filter((server) => server.id !== button.dataset.id);
const nextActive =
getState().settings.activeServerId === button.dataset.id ? nextServers[0]?.id || null : getState().settings.activeServerId;
setSettings({ ...getState().settings, servers: nextServers, activeServerId: nextActive });
settingsNotice = "Server removed.";
render();
});
});
document.querySelectorAll(".edit-server-btn").forEach((button) => {
button.addEventListener("click", () => openServerForm(button.dataset.id));
});
}
function render() {
if (!getState().settings.servers.length) {
// First launch always lands in setup until at least one backend is saved.
welcomeView();
return;
}
dashboardView();
}
window.addEventListener("DOMContentLoaded", async () => {
await loadRepositories();
render();
});