diff --git a/README.md b/README.md index 7fdc36c..aaa49bb 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,44 @@ It connects to any compatible self-hosted Gitea instance (including Gitpub) with - Rust Git commands: clone, pull, push, status, branch - Settings for git path, clone directory, and theme placeholder -## Run +## Development + +### Prerequisites + +- [Node.js](https://nodejs.org/) (npm comes with it) +- [Rust](https://www.rust-lang.org/tools/install) via `rustup` (required for the Tauri backend) +- Platform-specific Tauri dependencies (WebView2 on Windows, Xcode tools on macOS, etc.) — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS +- **Git** installed and available on your `PATH` (the app shells out to `git` for clone, pull, push, and related commands) + +### Start the app in dev mode + +From the repository root: ```bash npm install -npm run tauri:dev +npm run dev ``` + +This runs the Tauri dev process, which loads the static frontend from `frontend/` and hot-reloads the Rust side when you change `src-tauri/`. Edit HTML/CSS/JS under `frontend/` and refresh the window (or rely on your usual workflow) as needed. + +### Production build + +```bash +npm install +npm run tauri:build +``` + +Installers and bundles are emitted under `src-tauri/target/release/bundle/` (exact paths depend on the target platform). + +### Useful commands + +| Command | Purpose | +| --- | --- | +| `npm run dev` | Desktop app in development mode | +| `npm run tauri:build` | Release build and platform bundles | +| `npm run tauri -- ` | Forward arguments to the Tauri CLI (e.g. `npm run tauri -- info`) | + +### Notes + +- There is no separate Vite/webpack dev server; the UI is plain static files under `frontend/`. +- Point the app at a running Gitea-compatible instance during setup; API calls use `/api/v1`. diff --git a/frontend/css/components.css b/frontend/css/components.css index 1e0097a..f994403 100644 --- a/frontend/css/components.css +++ b/frontend/css/components.css @@ -15,14 +15,21 @@ .rightbar { background: rgba(13, 21, 34, 0.88); border-right: 1px solid var(--border); - padding: 14px; +} + +.sidebar { + padding: 16px; + overflow-y: auto; } .rightbar { border-right: 0; border-left: 1px solid var(--border); + padding: 0 12px 12px; + overflow-y: auto; } + .main { padding: 16px; overflow: auto; @@ -69,6 +76,54 @@ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } +.repo-filter-pills { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.pill-btn { + padding: 4px 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.pill-btn:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text-main); +} + +.pill-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.org-group { + gap: 8px; +} + +.org-group-header { + display: flex; + align-items: center; + gap: 10px; +} + +.org-badge { + font-size: 13px; + font-weight: 600; + color: var(--text-main); + background: rgba(255, 255, 255, 0.07); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 2px 10px; +} + .muted { color: var(--text-muted); } @@ -131,6 +186,165 @@ color: var(--danger); } +/* ── Tabs ─────────────────────────────────────────── */ +.tabs { + display: flex; + align-items: center; + gap: 2px; + border-bottom: 1px solid var(--border); + padding: 0 4px; + flex-shrink: 0; +} + +.tab-btn { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding: 10px 14px; + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease; + margin-bottom: -1px; +} + +.tab-btn:hover { + transform: none; + color: var(--text-main); + border-color: transparent; + background: transparent; +} + +.tab-btn.active { + color: var(--text-main); + border-bottom-color: var(--accent); +} + +.tab-spacer { + flex: 1; +} + +.tab-search { + width: 180px !important; + padding: 6px 10px !important; + font-size: 13px; + margin: 4px 4px 4px 0; +} + +.main-tabs { + margin-bottom: 0; +} + +.right-tabs { + position: sticky; + top: 0; + margin: 0 -12px; + padding: 0 12px; + background: rgba(13, 21, 34, 0.96); + z-index: 1; +} + +/* ── Sidebar ──────────────────────────────────────── */ +.sidebar-brand .title { + font-size: 16px; +} + +.sidebar .server-chip { + overflow: hidden; +} + +.sidebar .server-chip > div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-nav { + display: flex; + gap: 6px; +} + +.sidebar-btn { + flex: 1; + font-size: 12px; + padding: 7px 8px; + text-align: center; +} + +.sidebar-recents { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-height: 0; +} + +.recent-item { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + color: var(--text-muted); + padding: 3px 0; + font-size: 13px; +} + +.recent-item:hover { + color: var(--text-main); +} + +/* ── Git output ───────────────────────────────────── */ +.git-output { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px 14px; + font-size: 12px; + font-family: "Fira Code", "Cascadia Code", "Consolas", monospace; + color: var(--success); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + line-height: 1.6; +} + +.git-output-placeholder { + font-size: 13px; + font-style: italic; + margin: 0; +} + +.commit-note { + font-size: 11px; + margin-top: 4px; +} + +/* ── Local repo indicator ─────────────────────────── */ +.selected-repo { + font-size: 13px; + padding: 7px 10px; + background: rgba(79, 157, 255, 0.08); + border: 1px solid rgba(79, 157, 255, 0.18); + border-radius: var(--radius-md); + word-break: break-all; +} + +/* ── Empty state ──────────────────────────────────── */ +.empty-state { + grid-column: 1 / -1; + text-align: center; + padding: 40px 16px; +} + +.empty-state > div:first-child { + font-size: 15px; + color: var(--text-main); + margin-bottom: 6px; +} + +/* ── Responsive ───────────────────────────────────── */ @media (max-width: 1180px) { .layout { grid-template-columns: 220px 1fr; diff --git a/frontend/js/app.js b/frontend/js/app.js index 6f3b671..a386fac 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,4 +1,4 @@ -import { fetchRepositories } from "./gitea-api.js"; +import { fetchCurrentUser, fetchRepositories } from "./gitea-api.js"; import { getActiveServer, getState, setSettings, updateSettings, addRecentRepo } from "./state.js"; import { runGitBranch, @@ -12,15 +12,21 @@ import { 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" }, + { 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)}`; @@ -105,10 +111,54 @@ function welcomeView() { 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(); - if (!searchTerm) return repositories; - return repositories.filter((repo) => repo.full_name.toLowerCase().includes(searchTerm)); + 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 ` +
+
${escapeHtml(repo.full_name)}
+
${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")}
+
+ + +
+
+ `; } function dashboardView() { @@ -119,128 +169,171 @@ function dashboardView() { appRoot.innerHTML = `
-
-
-

Repository Dashboard

-

Browse, clone, and open repositories across multiple Gitea backends.

-
-
- -
+
+ + + ${activeMainTab === "repos" + ? `` + : ""}
-
-
-

Repositories

- ${visibleRepos.length} shown -
-
- ${visibleRepos - .map( - (repo) => ` -
-
${escapeHtml(repo.full_name)}
-
${repo.private ? "Private" : "Public"} • Updated ${escapeHtml(repo.updated_at || "recently")}
-
- - + ${activeMainTab === "repos" ? ` +
+
+

Repositories

+ ${visibleRepos.length} shown +
+
+ + + +
+ ${repoOwnerFilter === "orgs" + ? (() => { + const groups = groupedByOrg(visibleRepos); + const orgNames = Object.keys(groups).sort(); + if (!orgNames.length) { + return `
No organisation repositories found
Try refreshing or check your server connection.
`; + } + return orgNames.map((org) => ` +
+
+ ${escapeHtml(org)} + ${groups[org].length} repo${groups[org].length !== 1 ? "s" : ""} +
+
+ ${groups[org].map((repo) => repoCardTemplate(repo)).join("")} +
-
- ` - ) - .join("")} -
-
- -
-

Repository View

-
- - -
-
Current repository: ${escapeHtml(state.selectedRepoName || "None selected")}
-
- - - - -
-
-
Commit message (MVP placeholder)
- - -
-
${escapeHtml(gitOutput || "Run a git action to see output.")}
-
+ `).join(""); + })() + : `
+ ${visibleRepos.length + ? visibleRepos.map((repo) => repoCardTemplate(repo)).join("") + : `
+
No repositories found
+
Try refreshing or check your server connection.
+
`} +
`} + + ` : ` +
+

Local Repository

+
+ + +
+ ${state.selectedRepoName + ? `
Active: ${escapeHtml(state.selectedRepoName)}
` + : ""} +
+ + + + +
+
+
Commit message
+ +
Commit support coming in the next milestone.
+
+ ${gitOutput + ? `
${escapeHtml(gitOutput)}
` + : `

Run a git command to see output here.

`} +
+ `}
`; @@ -327,15 +420,21 @@ function openServerForm(serverId = null) { async function loadRepositories() { const activeServer = getActiveServer(); if (!activeServer) { - // Empty setup defaults to mock cards so UI can be tested immediately. repositories = [...mockRepos]; + currentUserLogin = "alice"; // mock user matches mock personal repos return; } try { - repositories = await fetchRepositories(activeServer); + 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]; } } @@ -371,13 +470,35 @@ function bindDashboardEvents() { }); document.getElementById("open-settings-btn")?.addEventListener("click", () => { - const panel = document.getElementById("server-form-slot"); - if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" }); + 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(); }); }); @@ -385,6 +506,7 @@ function bindDashboardEvents() { document.querySelectorAll(".clone-repo-btn").forEach((button) => { button.addEventListener("click", () => { state.cloneUrlInput = button.dataset.cloneUrl || ""; + activeRightTab = "clone"; render(); }); }); diff --git a/frontend/js/gitea-api.js b/frontend/js/gitea-api.js index 36ea627..faa6412 100644 --- a/frontend/js/gitea-api.js +++ b/frontend/js/gitea-api.js @@ -27,6 +27,17 @@ export function buildHeaders(serverConfig) { return headers; } +export async function fetchCurrentUser(serverConfig) { + const apiBase = buildApiBaseUrl(serverConfig.serverUrl); + const response = await fetch(`${apiBase}/user`, { + headers: buildHeaders(serverConfig), + }); + if (!response.ok) { + throw new Error(`Gitea API error: ${response.status}`); + } + return response.json(); +} + 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`; diff --git a/package.json b/package.json index 4d9553e..de3c9e5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "tauri": "tauri", - "tauri:dev": "tauri dev", + "dev": "tauri dev", "tauri:build": "tauri build" }, "devDependencies": {