6b245c628c
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).
489 lines
18 KiB
JavaScript
489 lines
18 KiB
JavaScript
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("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
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();
|
|
});
|