diff --git a/app/routers/board/_common.py b/app/routers/board/_common.py
index 9b3b6ec..40b5779 100644
--- a/app/routers/board/_common.py
+++ b/app/routers/board/_common.py
@@ -456,10 +456,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
has_active_workspace = False
local_ws_id = 0
- if ws_cookie and ws_cookie.startswith("gitea:"):
- # Gitea workspace: preserve context across pages. Also load the local
- # mirror workspace so it appears in "My Workspaces" in the top section
- # of the sidebar, in parallel with the Gitea repository tree.
+ if ws_cookie and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
+ # Forge workspace (Gitea/GitHub): preserve context across pages. Also
+ # load the local mirror workspace so it appears in Workspace in
+ # parallel with the Repository tree (same navigation as workspace-local).
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
gitea_owner = parts[1]
@@ -472,8 +472,9 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
try:
with get_conn() as conn:
row = conn.execute(
- "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
- (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
+ "SELECT id FROM workspaces WHERE owner_id=? AND name=? "
+ "AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
+ (user["id"], f"{gitea_owner}/{gitea_repo}"),
).fetchone()
if row:
local_ws_id = row["id"]
diff --git a/app/routers/board/board_views.py b/app/routers/board/board_views.py
index 219d02f..7d5f917 100644
--- a/app/routers/board/board_views.py
+++ b/app/routers/board/board_views.py
@@ -34,8 +34,19 @@ router = APIRouter(tags=["board"], prefix="/board")
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
+ from app.auth.session import SessionManager
+ from app.routers.dashboard._common import _cleanup_empty_mirrors, _ensure_forge_mirror
from app.templating import ENV
env = ENV
+ # Selecting a repository also opens a workspace-local of the same name:
+ # ensure the mirror exists, prune other empty mirrors.
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if user and owner and repo:
+ try:
+ mid = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea")
+ _cleanup_empty_mirrors(user["id"], keep_name=f"{owner}/{repo}", keep_id=mid or 0)
+ except Exception:
+ logger.exception("board mirror ensure")
sidebar = _sidebar_data(request, owner, repo)
template = env.get_template("board.html")
return template.render(request=request, owner=owner, repo=repo, groups=[],
diff --git a/app/routers/dashboard/_common.py b/app/routers/dashboard/_common.py
index cb3b3d0..15d0543 100644
--- a/app/routers/dashboard/_common.py
+++ b/app/routers/dashboard/_common.py
@@ -134,7 +134,8 @@ def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
return []
-def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
+def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True,
+ gitea_owner: str = "", gitea_repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B"
@@ -174,37 +175,45 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
active_ws_name = "Workspace"
workspace_pages = []
gitea_workspace = False
- gitea_owner = ""
- gitea_repo = ""
has_active_workspace = False
local_ws_id = 0
- if ws_cookie and ws_cookie.startswith("gitea:"):
- # Gitea workspace: set owner/repo for client-side tree loading
- # AND open the local workspace mirror of the same name in the
- # sidebar's top "My Workspaces" section, in parallel with the
- # Gitea repository tree.
+ # Explicit project context (e.g. /gitea-workspace?owner=…&repo=…) wins over
+ # the cookie: on a direct navigation / bookmark the cookie may be missing,
+ # yet the project is unambiguously identified by the URL. Without this the
+ # sidebar showed "No workspace open" even though the route just created the
+ # local mirror.
+ eff_owner, eff_repo = gitea_owner, gitea_repo
+ if not (eff_owner and eff_repo) and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
- gitea_owner = parts[1]
- gitea_repo = parts[2]
- active_ws_name = f"{gitea_owner}/{gitea_repo}"
- gitea_workspace = True
- has_active_workspace = True
- # Load the local mirror workspace tree so it appears in "My
- # Workspaces" alongside the Gitea repository section.
- if user:
- try:
- with get_conn() as conn:
- row = conn.execute(
- "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
- (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
- ).fetchone()
- if row:
- local_ws_id = row["id"]
- workspace_pages = _load_workspace_pages(str(local_ws_id))
- except (ValueError, Exception):
- pass
+ eff_owner, eff_repo = parts[1], parts[2]
+
+ if eff_owner and eff_repo:
+ # Forge workspace (Gitea or GitHub): set owner/repo for client-side
+ # tree loading AND open the local workspace mirror of the same name
+ # in the sidebar's top Workspace section, in parallel with the
+ # Repository tree (same navigation structure as workspace-local).
+ gitea_owner = eff_owner
+ gitea_repo = eff_repo
+ active_ws_name = f"{gitea_owner}/{gitea_repo}"
+ gitea_workspace = True
+ has_active_workspace = True
+ # Load the local mirror workspace tree so it appears in Workspace
+ # alongside the Repository section (gitea_repo or github_repo marker).
+ if user:
+ try:
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT id FROM workspaces WHERE owner_id=? AND name=? "
+ "AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
+ (user["id"], f"{gitea_owner}/{gitea_repo}"),
+ ).fetchone()
+ if row:
+ local_ws_id = row["id"]
+ workspace_pages = _load_workspace_pages(str(local_ws_id))
+ except (ValueError, Exception):
+ pass
elif include_workspace and ws_cookie and user:
try:
wsi = int(ws_cookie)
@@ -248,15 +257,16 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception:
logger.exception("_sidebar_data")
- # Get local workspace ID for Gitea workspace mirror (le miroir Gitea est un
+ # Get local workspace ID for forge workspace mirror (le miroir est un
# espace DISTINCT : on ne doit pas écraser ``local_ws_id`` (espace actif).
gitea_mirror_ws_id = 0
if gitea_workspace and gitea_owner and gitea_repo:
try:
with get_conn() as conn:
row = conn.execute(
- "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
- (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
+ "SELECT id FROM workspaces WHERE owner_id=? AND name=? "
+ "AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
+ (user["id"], f"{gitea_owner}/{gitea_repo}"),
).fetchone()
if row:
gitea_mirror_ws_id = row["id"]
@@ -614,6 +624,103 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
+def _ensure_forge_mirror(user_id: int, owner: str, repo: str, forge: str = "gitea") -> int | None:
+ """Ensure the local workspace mirror ``owner/repo`` exists, return its id.
+
+ Shared by /gitea-workspace and /github-workspace so selecting a repository
+ always opens a workspace-local of the same name (section Workspace).
+ Only auto-created mirrors carry ``settings_json`` with ``gitea_repo`` /
+ ``github_repo`` — never touch regular user workspaces.
+ """
+ import json as _json
+
+ ws_key = f"{owner}/{repo}" if owner and repo else ""
+ if not ws_key or not user_id:
+ return None
+ marker = f"{forge}_repo"
+ with get_conn() as conn:
+ existing = conn.execute(
+ "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
+ (user_id, ws_key, f"%{marker}%"),
+ ).fetchone()
+ if existing:
+ return existing["id"]
+ cur = conn.execute(
+ "INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
+ (ws_key, user_id, _json.dumps({marker: ws_key, f"{forge}_owner": owner})),
+ )
+ mirror_id = cur.lastrowid
+ conn.execute(
+ "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
+ (mirror_id, user_id, "admin"),
+ )
+ conn.commit()
+ return mirror_id
+
+
+def _is_mirror_empty(conn, ws_id: int) -> bool:
+ """True when no file/folder was ever created in this mirror workspace.
+
+ « Vide » = zéro page active (``deleted_at IS NULL``). Trash / soft-deleted
+ rows don't count — an emptied-then-trashed mirror is still collectable.
+ """
+ row = conn.execute(
+ "SELECT COUNT(*) AS c FROM pages WHERE workspace_id=? AND deleted_at IS NULL",
+ (ws_id,),
+ ).fetchone()
+ return (row["c"] if row else 0) == 0
+
+
+def _cleanup_empty_mirrors(user_id: int, keep_name: str = "", keep_id: int = 0) -> int:
+ """Delete empty auto-created forge mirrors, except the one being opened.
+
+ Called when switching workspace / repository so empty workspace-locals
+ don't accumulate. Only workspaces whose ``settings_json`` contains
+ ``gitea_repo`` or ``github_repo`` are eligible — regular workspaces are
+ never deleted here. Returns number of removed mirrors.
+ """
+ if not user_id:
+ return 0
+ removed = 0
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT id, name FROM workspaces WHERE owner_id=? "
+ "AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
+ (user_id,),
+ ).fetchall()
+ for r in rows:
+ wid, name = r["id"], r["name"]
+ if keep_id and wid == keep_id:
+ continue
+ if keep_name and name == keep_name:
+ continue
+ try:
+ if not _is_mirror_empty(conn, wid):
+ continue
+ except Exception:
+ continue
+ try:
+ from app.services.collection_lifecycle import (
+ delete_collections,
+ workspace_collection_ids,
+ )
+ try:
+ collections = workspace_collection_ids(conn, wid)
+ if collections:
+ delete_collections(conn, collections)
+ except Exception:
+ logger.exception("_cleanup_empty_mirrors collections")
+ conn.execute("DELETE FROM pages WHERE workspace_id=?", (wid,))
+ conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (wid,))
+ conn.execute("DELETE FROM workspaces WHERE id=?", (wid,))
+ removed += 1
+ except Exception:
+ logger.exception("_cleanup_empty_mirrors")
+ continue
+ conn.commit()
+ return removed
+
+
def _sanitize_id(block_id: str) -> str:
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
if not block_id:
diff --git a/app/routers/dashboard/account_settings.py b/app/routers/dashboard/account_settings.py
index ce3f518..a7f8631 100644
--- a/app/routers/dashboard/account_settings.py
+++ b/app/routers/dashboard/account_settings.py
@@ -395,9 +395,33 @@ def sidebar_workspace_tree(request: Request):
if not ws_cookie:
return HTMLResponse('
')
- # Gitea workspace — no server-side tree, loaded client-side
- if ws_cookie.startswith("gitea:"):
- return HTMLResponse('')
+ # Forge workspace (Gitea/GitHub) — the sidebar Workspace section shows the
+ # LOCAL mirror of the repository (the "parallel local workspace"), not a
+ # remote placeholder. Older behaviour returned "Remote workspace", which
+ # wiped the mirror tree on every CRUD refresh.
+ if ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:"):
+ parts = ws_cookie.split(":", 2)
+ if len(parts) >= 3:
+ mirror = None
+ with get_conn() as conn:
+ mirror = conn.execute(
+ "SELECT id FROM workspaces WHERE owner_id=? AND name=? "
+ "AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
+ (user["id"], f"{parts[1]}/{parts[2]}"),
+ ).fetchone()
+ if mirror:
+ pages = _load_workspace_pages(str(mirror["id"]))
+ if pages:
+ env = ENV
+ template = env.from_string(
+ "{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
+ "{{ render_workspace_tree(pages) }}"
+ )
+ return HTMLResponse(template.render(pages=pages))
+ return HTMLResponse(
+ ''
+ )
try:
ws_id = int(ws_cookie)
diff --git a/app/routers/dashboard/local_workspace.py b/app/routers/dashboard/local_workspace.py
index 4c1a6a1..02ddbe1 100644
--- a/app/routers/dashboard/local_workspace.py
+++ b/app/routers/dashboard/local_workspace.py
@@ -18,6 +18,7 @@ from ._common import (
WORKSPACE_COOKIE,
_build_breadcrumb,
_build_tree_children,
+ _cleanup_empty_mirrors,
_file_page_disk_path,
_get_active_workspace,
_get_user_id,
@@ -64,6 +65,12 @@ def local_workspace_page(request: Request, folder: int = None, ws: int = None):
).fetchone()
if requested is not None:
ws_row = dict(requested)
+ # Switching workspace: drop other EMPTY forge mirrors (no file or
+ # folder ever created there) instead of keeping empty locals.
+ try:
+ _cleanup_empty_mirrors(user["id"], keep_id=ws_row["id"])
+ except Exception:
+ pass
# L'URL fait foi : on aligne le cookie pour que le reste de
# l'application (sidebar, API tree, breadcrumbs) suive le même
# workspace que celui affiché.
diff --git a/app/routers/dashboard/workspace.py b/app/routers/dashboard/workspace.py
index d6ad685..03ac2c5 100644
--- a/app/routers/dashboard/workspace.py
+++ b/app/routers/dashboard/workspace.py
@@ -13,7 +13,13 @@ from app.auth.session import SessionManager
from app.db import get_conn
from app.services.gitea_client import get_user_gitea_client, gitea
-from ._common import _get_active_workspace, _get_user_id, _sidebar_data
+from ._common import (
+ _cleanup_empty_mirrors,
+ _ensure_forge_mirror,
+ _get_active_workspace,
+ _get_user_id,
+ _sidebar_data,
+)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@@ -125,12 +131,9 @@ def workspace_page(request: Request):
@router.get("/gitea-workspace", response_class=HTMLResponse)
def gitea_workspace_page(request: Request):
- """Gitea workspace — browse repo files."""
- import json
-
+ """Gitea workspace — browse repo files (same layout as /local-workspace)."""
from app.templating import ENV
env = ENV
- sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
@@ -138,27 +141,16 @@ def gitea_workspace_page(request: Request):
repo = request.query_params.get("repo", "")
ws_key = f"{owner}/{repo}" if owner and repo else ""
ws_name = ws_key or "Gitea Workspace"
- # Auto-create local workspace mirror for storing local files
- local_ws_id = None
+ # Ensure the local workspace mirror of the same name BEFORE building the
+ # sidebar, then drop other EMPTY mirrors so switching repositories never
+ # accumulates empty workspace-locals.
+ local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea") if ws_key else None
if ws_key:
- with get_conn() as conn:
- existing = conn.execute(
- "SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
- (user["id"], ws_key, "%gitea_repo%")
- ).fetchone()
- if existing:
- local_ws_id = existing["id"]
- else:
- c = conn.execute(
- "INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
- (ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
- )
- local_ws_id = c.lastrowid
- conn.execute(
- "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
- (local_ws_id, user["id"], "admin")
- )
- conn.commit()
+ _cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
+ # Build the sidebar with the explicit project context so the local mirror
+ # (''Workspace'' section) is resolved even when the flowdeck_workspace
+ # cookie is absent (direct navigation / bookmark).
+ sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
ctx = {
**sidebar,
"user": user,
@@ -167,6 +159,7 @@ def gitea_workspace_page(request: Request):
"gitea_workspace": True, # always true on this page
"gitea_owner": owner,
"gitea_repo": repo,
+ "forge": "gitea",
"workspace_name": ws_name,
"workspace_initial": repo[0].upper() if repo else "G",
"owner": owner,
@@ -179,6 +172,45 @@ def gitea_workspace_page(request: Request):
return resp
+@router.get("/github-workspace", response_class=HTMLResponse)
+def github_workspace_page(request: Request):
+ """GitHub workspace — same layout as /local-workspace and /gitea-workspace."""
+ from app.templating import ENV
+ env = ENV
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user:
+ return RedirectResponse("/auth/login?provider=local", status_code=302)
+ owner = request.query_params.get("owner", "")
+ repo = request.query_params.get("repo", "")
+ ws_key = f"{owner}/{repo}" if owner and repo else ""
+ ws_name = ws_key or "GitHub Workspace"
+ local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="github") if ws_key else None
+ if ws_key:
+ _cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
+ # Reuse the same sidebar mechanism (Repository section + parallel local
+ # mirror). The cookie prefix carries the forge so refresh APIs resolve it.
+ sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
+ ctx = {
+ **sidebar,
+ "user": user,
+ "active_ws_name": ws_name,
+ "workspace_key": ws_key,
+ "gitea_workspace": True, # sidebar Repository section visible
+ "gitea_owner": owner,
+ "gitea_repo": repo,
+ "forge": "github",
+ "workspace_name": ws_name,
+ "workspace_initial": repo[0].upper() if repo else "G",
+ "owner": owner,
+ "repo": repo,
+ "nav_workspace_id": local_ws_id or 0,
+ }
+ template = env.get_template("github_workspace.html")
+ resp = HTMLResponse(content=template.render(**ctx))
+ resp.set_cookie("flowdeck_workspace", f"github:{owner}:{repo}", path="/", samesite="lax")
+ return resp
+
+
@router.get("/api/workspace/projects")
diff --git a/app/routers/dashboard/workspaces.py b/app/routers/dashboard/workspaces.py
index f91e6a5..7da3795 100644
--- a/app/routers/dashboard/workspaces.py
+++ b/app/routers/dashboard/workspaces.py
@@ -18,6 +18,7 @@ from app.services.collection_lifecycle import (
from ._common import (
WORKSPACE_COOKIE,
+ _cleanup_empty_mirrors,
_get_active_workspace,
_get_user_id,
_require_user_id,
@@ -158,7 +159,12 @@ def delete_workspace(request: Request, ws_id: int):
@router.post("/api/workspaces/{ws_id:int}/select")
def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie."""
- _require_ws_owner(request, ws_id)
+ uid = _require_ws_owner(request, ws_id)
+ # Switching workspace: prune other empty forge mirrors.
+ try:
+ _cleanup_empty_mirrors(uid, keep_id=ws_id)
+ except Exception:
+ pass
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
return response
diff --git a/app/routers/github_routes.py b/app/routers/github_routes.py
index fd00bc5..ca80a7b 100644
--- a/app/routers/github_routes.py
+++ b/app/routers/github_routes.py
@@ -1,10 +1,30 @@
-"""GitHub OAuth — status and disconnect routes."""
-from fastapi import APIRouter, Request
+"""GitHub OAuth + repository browsing (mirrors /api/gitea shape for shared UI)."""
+from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["github"], prefix="/api/github")
+def _require_github(request: Request):
+ """Return a per-user GitHubAdapter or raise 401 (same contract as Gitea)."""
+ from app.auth.session import SessionManager
+ from app.db import get_conn
+ from app.services.github_adapter import GitHubAdapter
+
+ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+ if not user:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' "
+ "AND access_token IS NOT NULL AND access_token != '' ORDER BY updated_at DESC LIMIT 1",
+ (user["id"],),
+ ).fetchone()
+ if not row:
+ raise HTTPException(status_code=401, detail="GitHub account not linked. Go to Settings → Integrations to connect.")
+ return GitHubAdapter(row["access_token"])
+
+
@router.get("/status")
def github_status(request: Request):
"""Check if the current user has GitHub linked."""
@@ -33,3 +53,68 @@ def disconnect_github(request: Request):
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],))
conn.commit()
return {"status": "ok"}
+
+
+# ── Repo tree (same shape as /api/gitea/projects/{owner}/{repo}/tree) ──
+@router.get("/projects/{owner}/{repo}/tree")
+async def github_repo_tree(request: Request, owner: str, repo: str, path: str = ""):
+ """List one level of a GitHub repo (folders first client-side)."""
+ adapter = _require_github(request)
+ try:
+ tree = await adapter.get_repo_tree(owner, repo)
+ prefix = path.strip("/").strip()
+ items = []
+ seen_dirs: set[str] = set()
+ for entry in tree:
+ p = (entry.get("path") or "").strip("/")
+ if not p:
+ continue
+ if prefix:
+ if p != prefix and not p.startswith(prefix + "/"):
+ continue
+ rel = p[len(prefix) + 1:] if p != prefix else ""
+ else:
+ rel = p
+ if not rel:
+ continue
+ if "/" in rel:
+ top = rel.split("/")[0]
+ dir_path = f"{prefix}/{top}".strip("/") if prefix else top
+ if dir_path not in seen_dirs:
+ seen_dirs.add(dir_path)
+ items.append({"name": top, "path": dir_path, "type": "folder", "size": 0, "sha": ""})
+ else:
+ is_dir = entry.get("type") == "tree"
+ items.append({
+ "name": rel,
+ "path": p,
+ "type": "folder" if is_dir else "file",
+ "size": entry.get("size", 0),
+ "sha": entry.get("sha", ""),
+ })
+ # De-duplicate (recursive tree can list both tree + blob entries)
+ dedup: dict[str, dict] = {}
+ for it in items:
+ key = it["path"]
+ if key not in dedup or (dedup[key]["type"] == "file" and it["type"] == "folder"):
+ dedup[key] = it
+ return {"tree": sorted(dedup.values(), key=lambda x: (x["type"] != "folder", x["name"].lower()))}
+ except HTTPException:
+ raise
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
+
+
+@router.get("/projects/{owner}/{repo}/file")
+async def github_get_file(request: Request, owner: str, repo: str, path: str = ""):
+ """Get decoded file content (same shape as Gitea endpoint)."""
+ adapter = _require_github(request)
+ if not path:
+ return JSONResponse({"error": "Path required"}, status_code=400)
+ try:
+ content = await adapter.get_file_content(owner, repo, path)
+ return {"content": content, "path": path}
+ except HTTPException:
+ raise
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
diff --git a/app/templates/base.html b/app/templates/base.html
index 380b24b..bf400ff 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -329,16 +329,18 @@
-
+
{% if gitea_workspace %}