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 %}