Initial commit: Gitpub Desktop scaffold

Add complete project scaffold for Gitpub Desktop (Tauri + Rust backend and vanilla HTML/CSS/JS frontend). Includes frontend entry (index.html), styles (base/components CSS), app logic and modules (app.js, gitea-api.js, tauri-api.js, state.js, storage.js), static assets and component READMEs, README.md, VSCode recommendations, and project config (package.json, package-lock.json). Also adds src-tauri skeleton (Cargo.toml, main.rs, lib.rs, build.rs, tauri.conf.json), application icons, and .gitignore files. Provides MVP plumbing for server setup and management, repository dashboard, local repo opening, and Git operations via Tauri invoke (clone/pull/push/status/branch).
This commit is contained in:
2026-05-07 14:41:15 +12:00
commit 6b245c628c
44 changed files with 7105 additions and 0 deletions
+488
View File
@@ -0,0 +1,488 @@
import { 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: "gitpub-desktop/client-ui", private: false, clone_url: "https://example.com/client-ui.git", updated_at: "today" },
{ id: 2, full_name: "gitpub-desktop/git-core", private: true, clone_url: "https://example.com/git-core.git", updated_at: "yesterday" },
{ id: 3, full_name: "gitpub-desktop/docs", private: false, clone_url: "https://example.com/docs.git", updated_at: "2 days ago" },
];
let repositories = [...mockRepos];
let serverTestResult = "";
let settingsNotice = "";
let gitOutput = "";
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 filteredRepositories() {
const searchTerm = getState().repoSearch.trim().toLowerCase();
if (!searchTerm) return repositories;
return repositories.filter((repo) => repo.full_name.toLowerCase().includes(searchTerm));
}
function dashboardView() {
const state = getState();
const activeServer = getActiveServer();
const visibleRepos = filteredRepositories();
appRoot.innerHTML = `
<div class="layout">
<aside class="sidebar stack">
<div>
<h2 class="title">Gitpub Desktop</h2>
<p class="subtitle">Lightweight Gitea Git client</p>
</div>
<div class="server-chip">
<div class="label">Active backend</div>
<div>${escapeHtml(activeServer?.displayName || "None")}</div>
<div class="muted">${escapeHtml(activeServer?.serverUrl || "Not configured")}</div>
</div>
<button id="refresh-repos-btn" type="button">Refresh repositories</button>
<button id="open-settings-btn" type="button">Settings</button>
<div class="panel">
<div class="label">Recent local repositories</div>
<ul class="list">
${state.settings.recentRepositories.length
? state.settings.recentRepositories.map((path) => `<li>${escapeHtml(path)}</li>`).join("")
: "<li class='muted'>No repositories opened yet.</li>"}
</ul>
</div>
</aside>
<main class="main stack">
<div class="app-header">
<div>
<h2 class="title">Repository Dashboard</h2>
<p class="subtitle">Browse, clone, and open repositories across multiple Gitea backends.</p>
</div>
<div class="row">
<input id="repo-search-input" placeholder="Search repositories..." value="${escapeHtml(state.repoSearch)}" />
</div>
</div>
<section class="panel stack">
<div class="section-header">
<h3 class="title">Repositories</h3>
<span class="muted">${visibleRepos.length} shown</span>
</div>
<div class="repo-grid">
${visibleRepos
.map(
(repo) => `
<article class="repo-card stack">
<div><strong>${escapeHtml(repo.full_name)}</strong></div>
<div class="muted">${repo.private ? "Private" : "Public"} • Updated ${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>
`
)
.join("")}
</div>
</section>
<section class="panel stack">
<h3 class="title">Repository View</h3>
<div class="row">
<input id="local-repo-path-input" placeholder="Local repository path" value="${escapeHtml(state.localRepoPathInput)}" />
<button id="open-local-repo-btn" type="button">Open Local Repo</button>
</div>
<div class="muted">Current repository: ${escapeHtml(state.selectedRepoName || "None selected")}</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 (MVP placeholder)</div>
<textarea id="commit-message-input" placeholder="Write commit message...">${escapeHtml(state.commitMessage)}</textarea>
<button type="button" disabled title="Commit command will be added in next milestone">Commit (next milestone)</button>
</div>
<pre class="repo-card">${escapeHtml(gitOutput || "Run a git action to see output.")}</pre>
</section>
</main>
<aside class="rightbar stack">
<section class="panel stack">
<h3 class="title">Clone Repository</h3>
<input id="clone-url-input" placeholder="https://git.example.com/org/repo.git" value="${escapeHtml(state.cloneUrlInput)}" />
<input id="clone-destination-input" placeholder="/Users/me/code/repo" value="${escapeHtml(state.cloneDestinationInput || state.settings.defaultCloneDirectory)}" />
<button id="clone-btn" class="primary" type="button">Clone</button>
<span class="muted">Uses system Git executable from settings.</span>
</section>
<section class="panel stack">
<h3 class="title">Settings</h3>
<div class="label">Theme</div>
<select id="theme-select">
<option value="dark" ${state.settings.theme === "dark" ? "selected" : ""}>Dark (default)</option>
</select>
<div class="label">Git executable path</div>
<input id="git-path-input" value="${escapeHtml(state.settings.gitExecutablePath)}" placeholder="git or /usr/bin/git" />
<div class="label">Default clone directory</div>
<input id="default-clone-dir-input" value="${escapeHtml(state.settings.defaultCloneDirectory)}" placeholder="/Users/me/code" />
<button id="save-basic-settings-btn" type="button">Save basic settings</button>
</section>
<section class="panel stack">
<h3 class="title">Server Management</h3>
<div class="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("")}
</div>
<button id="add-server-btn" type="button">Add new server</button>
<div id="server-form-slot" class="stack"></div>
<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) {
// Empty setup defaults to mock cards so UI can be tested immediately.
repositories = [...mockRepos];
return;
}
try {
repositories = await fetchRepositories(activeServer);
} catch (error) {
settingsNotice = `Using mock repositories. API fetch failed: ${error.message}`;
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", () => {
const panel = document.getElementById("server-form-slot");
if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelectorAll(".open-repo-btn").forEach((button) => {
button.addEventListener("click", () => {
state.selectedRepoName = button.dataset.repoName || "Repository";
render();
});
});
document.querySelectorAll(".clone-repo-btn").forEach((button) => {
button.addEventListener("click", () => {
state.cloneUrlInput = button.dataset.cloneUrl || "";
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();
});
+41
View File
@@ -0,0 +1,41 @@
function normalizeServerUrl(serverUrl) {
return serverUrl.trim().replace(/\/+$/, "");
}
export function buildApiBaseUrl(serverUrl) {
const normalized = normalizeServerUrl(serverUrl);
return normalized.endsWith("/api/v1") ? normalized : `${normalized}/api/v1`;
}
export function buildHeaders(serverConfig) {
const headers = {
Accept: "application/json",
};
if (serverConfig.authMethod === "token" && serverConfig.token) {
headers.Authorization = `token ${serverConfig.token}`;
} else if (
serverConfig.authMethod === "password" &&
serverConfig.username &&
serverConfig.password
) {
headers.Authorization = `Basic ${btoa(
`${serverConfig.username}:${serverConfig.password}`
)}`;
}
return headers;
}
export async function fetchRepositories(serverConfig, page = 1, limit = 50) {
const apiBase = buildApiBaseUrl(serverConfig.serverUrl);
const url = `${apiBase}/user/repos?page=${page}&limit=${limit}&sort=updated`;
const response = await fetch(url, {
headers: buildHeaders(serverConfig),
});
if (!response.ok) {
throw new Error(`Gitea API error: ${response.status}`);
}
return response.json();
}
+37
View File
@@ -0,0 +1,37 @@
import { loadSettings, saveSettings } from "./storage.js";
const state = {
settings: loadSettings(),
selectedRepoPath: "",
selectedRepoName: "",
repoSearch: "",
localRepoPathInput: "",
cloneUrlInput: "",
cloneDestinationInput: "",
commitMessage: "",
};
export function getState() {
return state;
}
export function setSettings(nextSettings) {
state.settings = nextSettings;
saveSettings(state.settings);
}
export function updateSettings(patch) {
setSettings({ ...state.settings, ...patch });
}
export function addRecentRepo(path) {
if (!path) return;
const current = state.settings.recentRepositories.filter((item) => item !== path);
const next = [path, ...current].slice(0, 15);
updateSettings({ recentRepositories: next });
}
export function getActiveServer() {
const { activeServerId, servers } = state.settings;
return servers.find((server) => server.id === activeServerId) ?? null;
}
+29
View File
@@ -0,0 +1,29 @@
const STORAGE_KEY = "gitpub-desktop-settings-v1";
export function getDefaultSettings() {
return {
theme: "dark",
gitExecutablePath: "",
defaultCloneDirectory: "",
activeServerId: null,
servers: [],
recentRepositories: [],
};
}
export function loadSettings() {
try {
const rawValue = localStorage.getItem(STORAGE_KEY);
if (!rawValue) return getDefaultSettings();
const parsed = JSON.parse(rawValue);
return { ...getDefaultSettings(), ...parsed };
} catch (error) {
console.error("Failed to load settings:", error);
return getDefaultSettings();
}
}
export function saveSettings(settings) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
+41
View File
@@ -0,0 +1,41 @@
const invoke = window.__TAURI__?.core?.invoke;
function ensureInvoke() {
if (!invoke) {
throw new Error("Tauri invoke API is not available.");
}
}
export async function runGitClone(repoUrl, destinationPath, gitPath) {
ensureInvoke();
return invoke("git_clone", {
repoUrl,
destinationPath,
gitPath: gitPath || null,
});
}
export async function runGitPull(repoPath, gitPath) {
ensureInvoke();
return invoke("git_pull", { repoPath, gitPath: gitPath || null });
}
export async function runGitPush(repoPath, gitPath) {
ensureInvoke();
return invoke("git_push", { repoPath, gitPath: gitPath || null });
}
export async function runGitStatus(repoPath, gitPath) {
ensureInvoke();
return invoke("git_status", { repoPath, gitPath: gitPath || null });
}
export async function runGitBranch(repoPath, gitPath) {
ensureInvoke();
return invoke("git_branch", { repoPath, gitPath: gitPath || null });
}
export async function testGiteaConnection(payload) {
ensureInvoke();
return invoke("test_gitea_connection", payload);
}