diff --git a/.env.example b/.env.example index 63b6f67..929daf4 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ GITEA_OAUTH_CLIENT_ID= GITEA_OAUTH_CLIENT_SECRET= GITEA_WEBHOOK_SECRET= +# ── GitHub ── +GITHUB_OAUTH_CLIENT_ID= +GITHUB_OAUTH_CLIENT_SECRET= + # ── App ── APP_SECRET_KEY=change-me-to-random APP_HOST=0.0.0.0 diff --git a/app/auth/providers.py b/app/auth/providers.py index 38f6c8f..2a1ed04 100644 --- a/app/auth/providers.py +++ b/app/auth/providers.py @@ -244,8 +244,8 @@ def get_providers() -> list[OAuthProvider]: providers.append(gitea) github = GitHubProvider( - client_id=settings.github_client_id or "", - client_secret=settings.github_client_secret or "", + client_id=settings.github_oauth_client_id or "", + client_secret=settings.github_oauth_client_secret or "", redirect_uri=settings.oauth_redirect_uri or "", ) if github.is_enabled(): diff --git a/app/config.py b/app/config.py index bc6ff3f..2383c73 100644 --- a/app/config.py +++ b/app/config.py @@ -18,8 +18,8 @@ class Settings(BaseSettings): gitea_webhook_secret: str = "" # GitHub - github_client_id: str = "" - github_client_secret: str = "" + github_oauth_client_id: str = "" + github_oauth_client_secret: str = "" # Standalone mode standalone: bool = False # FLOWDECK_STANDALONE=true in .env diff --git a/app/routers/gitea.py b/app/routers/gitea.py index ad93150..764f1b3 100644 --- a/app/routers/gitea.py +++ b/app/routers/gitea.py @@ -106,6 +106,31 @@ async def save_file(request: Request, owner: str, repo: str): return JSONResponse({"error": str(e)}, status_code=502) +# ── Upload file (binary) ── +@router.post("/projects/{owner}/{repo}/upload") +async def upload_file(request: Request, owner: str, repo: str): + """Upload a binary file to the repo.""" + import base64 + gitea = _require_gitea(request) + try: + form = await request.form() + except Exception: + return JSONResponse({"error": "Invalid form data"}, status_code=400) + file = form.get("file") + folder = form.get("folder", "") + message = form.get("message", "Upload via FlowDeck") + if not file: + return JSONResponse({"error": "No file provided"}, status_code=400) + try: + content = await file.read() + encoded = base64.b64encode(content).decode("utf-8") + path = f"{folder.strip('/')}/{file.filename}".strip("/") if folder.strip("/") else file.filename + result = await gitea.create_or_update_file(owner, repo, path, encoded, message, sha=None) + return {"status": "ok", "path": path, "commit": result.get("commit", {})} + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=502) + + # ── Delete file ── @router.delete("/projects/{owner}/{repo}/file") async def delete_file(request: Request, owner: str, repo: str): @@ -266,3 +291,48 @@ async def delete_private_page(owner: str, repo: str, page_id: int, request: Requ ) conn.commit() return {"status": "ok"} + + +# ── Commit history for a file ── +@router.get("/projects/{owner}/{repo}/commits") +async def file_commits(request: Request, owner: str, repo: str, path: str = ""): + """Get recent commits for a specific file.""" + gitea = _require_gitea(request) + try: + commits = await gitea.get_file_commits(owner, repo, path) + return {"commits": commits} + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=502) + + +# ── Sync labels to tags ── +@router.post("/projects/{owner}/{repo}/sync-labels") +async def sync_labels(request: Request, owner: str, repo: str): + """Sync Gitea labels to FlowDeck tags for the current user.""" + from app.auth.session import get_current_user as gcu + from app.db import get_conn + user = await gcu(request) + if not user: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + gitea = _require_gitea(request) + try: + labels = await gitea.get_labels(owner, repo) + except Exception: + labels = [] + imported = 0 + with get_conn() as conn: + for label in labels: + name = label.get("name", "") + color = label.get("color", "#787774") + if not name: continue + existing = conn.execute( + "SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"]) + ).fetchone() + if not existing: + conn.execute( + "INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", + (name, f"#{color}", user["id"]), + ) + imported += 1 + conn.commit() + return {"status": "ok", "imported": imported, "total": len(labels)} diff --git a/app/services/gitea_client.py b/app/services/gitea_client.py index dc16583..ab660d2 100644 --- a/app/services/gitea_client.py +++ b/app/services/gitea_client.py @@ -386,6 +386,17 @@ class GiteaClient: for k in keys: del self._cache[k] + async def get_file_commits(self, owner: str, repo: str, path: str) -> list[dict]: + """Get recent commits affecting a specific file.""" + url = f"{self.base}/api/v1/repos/{owner}/{repo}/commits" + params = {"path": path, "limit": 20} + headers = {"Authorization": f"token {self.token[0] if self.token else ''}", "Accept": "application/json"} + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code == 200: + return resp.json() + return [] + # ── singleton ── diff --git a/app/services/github_adapter.py b/app/services/github_adapter.py new file mode 100644 index 0000000..f660a7d --- /dev/null +++ b/app/services/github_adapter.py @@ -0,0 +1,158 @@ +"""FlowDeck — GitHub API adapter with caching.""" +from __future__ import annotations + +import base64 +import logging +from datetime import datetime, timedelta +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +DEFAULT_TTL = 30 # seconds + + +class GitHubAdapter: + """Async GitHub API client (v3 REST) with simple TTL cache. + + Authenticated via OAuth2 Bearer token. + """ + + def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None: + self._base = "https://api.github.com" + self._headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + } + self._cache: dict[str, tuple[datetime, Any]] = {} + self._ttl = timedelta(seconds=ttl) + + # ── cache helpers ── + + def _cached(self, key: str) -> Any | None: + entry = self._cache.get(key) + if entry and entry[0] > datetime.now(): + return entry[1] + return None + + def _set_cache(self, key: str, value: Any) -> None: + self._cache[key] = (datetime.now() + self._ttl, value) + + # ── repos ── + + async def list_repos(self, page: int = 1, per_page: int = 50) -> list[dict]: + """List repositories for the authenticated user (paginated).""" + cache_key = f"repos:{page}:{per_page}" + cached = self._cached(cache_key) + if cached: + return cached + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + f"{self._base}/user/repos", + headers=self._headers, + params={"page": page, "per_page": per_page, "sort": "updated"}, + ) + resp.raise_for_status() + data = resp.json() + self._set_cache(cache_key, data) + return data + + async def list_all_repos(self) -> list[dict]: + """Fetch all user repos across pages (up to 5 pages / 250 repos).""" + all_repos: list[dict] = [] + for page in range(1, 6): + repos = await self.list_repos(page=page) + if not repos: + break + all_repos.extend(repos) + return all_repos + + # ── repo tree ── + + async def get_repo_tree( + self, owner: str, repo: str, sha: str | None = None, recursive: bool = True + ) -> list[dict]: + """Get the git tree for a repo. If ``sha`` is omitted, resolves the + default branch first.""" + cache_key = f"tree:{owner}:{repo}:{sha or 'default'}:{recursive}" + cached = self._cached(cache_key) + if cached: + return cached + + async with httpx.AsyncClient(timeout=15) as client: + # Resolve default branch commit SHA if not provided + if sha is None: + repo_info = await client.get( + f"{self._base}/repos/{owner}/{repo}", + headers=self._headers, + ) + repo_info.raise_for_status() + sha = repo_info.json()["default_branch"] + + # Now get the commit to find tree SHA + branch_resp = await client.get( + f"{self._base}/repos/{owner}/{repo}/branches/{sha}", + headers=self._headers, + ) + branch_resp.raise_for_status() + sha = branch_resp.json()["commit"]["commit"]["tree"]["sha"] + + params: dict[str, Any] = {} + if recursive: + params["recursive"] = "1" + + resp = await client.get( + f"{self._base}/repos/{owner}/{repo}/git/trees/{sha}", + headers=self._headers, + params=params, + ) + resp.raise_for_status() + data = resp.json() + items = data.get("tree", []) + # Truncated responses — fetch remaining pages if needed + while data.get("truncated", False): + logger.warning("GitHub tree truncated for %s/%s — results incomplete", owner, repo) + break + + self._set_cache(cache_key, items) + return items + + # ── file content ── + + async def get_file_content(self, owner: str, repo: str, path: str) -> str: + """Get decoded file content from a repo.""" + cache_key = f"file:{owner}:{repo}:{path}" + cached = self._cached(cache_key) + if cached: + return cached + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + f"{self._base}/repos/{owner}/{repo}/contents/{path}", + headers=self._headers, + ) + resp.raise_for_status() + item = resp.json() + + content = "" + if item.get("encoding") == "base64" and item.get("content"): + try: + content = base64.b64decode(item["content"]).decode("utf-8") + except Exception: + content = "[binary file]" + + self._set_cache(cache_key, content) + return content + + # ── token validation ── + + async def validate_token(self) -> bool: + """Check whether the access token is still valid.""" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + f"{self._base}/user", + headers=self._headers, + ) + return resp.status_code == 200 diff --git a/app/templates/base.html b/app/templates/base.html index 2ce1902..ecc072d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -529,6 +529,8 @@ contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' }, contextActions: {}, showNewPageMenu: false, + selectedItems: [], + lastClickedIndex: -1, // ── Workspace tree context menu ── wsCtx: { visible: false, x: 0, y: 0, id: null, name: '', isFolder: false }, @@ -1027,6 +1029,71 @@ window.addEventListener('pageshow', function(e) { if (e.persisted) applySavedTheme(); }); + + // ═══════════ GLOBAL KEYBOARD SHORTCUTS ═══════════ + document.addEventListener('keydown', function(e) { + var tag = (document.activeElement && document.activeElement.tagName) || ''; + var editable = tag === 'INPUT' || tag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable); + // Don't intercept when typing in inputs unless it's Escape + if (editable && e.key !== 'Escape') return; + // Don't intercept when a modal prompt is open + if (document.querySelector('.modal-overlay:not([style*="display:none"])')) return; + + var appEl = document.querySelector('[x-data]'); + var data = appEl && appEl.__x ? appEl.__x.$data : null; + + if (e.ctrlKey && e.key === 'n') { + e.preventDefault(); + if (data && data.newPageInWorkspace) data.newPageInWorkspace(); + else if (data && data.newPage) data.newPage('Private'); + } + else if (e.key === 'Enter' && !editable) { + var activeSidebar = document.querySelector('.sidebar-item.active'); + if (activeSidebar) { + e.preventDefault(); + activeSidebar.click(); + } + } + else if (e.key === 'F2') { + e.preventDefault(); + // Trigger rename: dispatch dblclick on active item's name span + var nameSpan = document.querySelector('.sidebar-item.active .page-name'); + if (nameSpan) { + nameSpan.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + } else if (data && data.wsCtx && data.wsCtx.id) { + // Workspace tree item: trigger rename via context menu + data.wsCtxAction('rename'); + } + } + else if (e.key === 'Delete' && !editable) { + e.preventDefault(); + var delItem = document.querySelector('.sidebar-item.active'); + if (delItem && delItem.dataset.pageId && data) { + data.contextMenu.pageId = delItem.dataset.pageId; + data.contextMenu.pageName = delItem.dataset.pageName || delItem.querySelector('.page-name')?.textContent || ''; + data.contextAction('delete'); + } else if (delItem && delItem.dataset.giteaPath) { + // Gitea file: dispatch custom event + delItem.dispatchEvent(new CustomEvent('gitea-delete', { bubbles: true })); + } + } + else if (e.key === 'Escape') { + // Close card modal + if (typeof closeModal === 'function') closeModal(); + // Close context menus + if (data) { + if (data.contextMenu) data.contextMenu.visible = false; + if (data.wsCtx) data.wsCtx.visible = false; + if (data.showNewPageMenu) data.showNewPageMenu = false; + } + } + else if (e.ctrlKey && e.key === 's') { + e.preventDefault(); + // Try to find and click a save/commit button + var saveBtn = document.querySelector('.btn-primary, button:contains("Save"), button:contains("Commit")'); + if (saveBtn) saveBtn.click(); + } + }); {% block scripts %}{% endblock %} diff --git a/app/templates/gitea_workspace.html b/app/templates/gitea_workspace.html index f88eec2..2be48c2 100644 --- a/app/templates/gitea_workspace.html +++ b/app/templates/gitea_workspace.html @@ -11,7 +11,10 @@ Home -