From 6f15ad3ad41de8d9c9265aa6473a8343475eca5f Mon Sep 17 00:00:00 2001 From: bruno Date: Mon, 13 Jul 2026 22:33:47 -0400 Subject: [PATCH] v2.8.0 WIP: create/upload files + commit history + labels sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/gitea/projects/{owner}/{repo}/upload (binary upload) - get_file_commits() in GiteaClient - GET /api/gitea/projects/{owner}/{repo}/commits (per-file history) - POST .../sync-labels (Gitea labels → FlowDeck tags) - New file form + upload button in gitea_workspace.html topbar --- .env.example | 4 + app/auth/providers.py | 4 +- app/config.py | 4 +- app/routers/gitea.py | 70 ++++++ app/services/gitea_client.py | 11 + app/services/github_adapter.py | 158 ++++++++++++ app/templates/base.html | 67 +++++ app/templates/gitea_workspace.html | 382 +++++++++++++++++++++++++++-- app/templates/workspaces.html | 16 +- 9 files changed, 689 insertions(+), 27 deletions(-) create mode 100644 app/services/github_adapter.py 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 {{ owner }}/{{ repo }} -
+
+ + +
@@ -19,11 +22,17 @@ {% endblock %} {% block content %} + + + +
- +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
@@ -152,6 +189,8 @@ function giteaWorkspace() { fileContent: '', fileSize: 0, fileSha: '', + fileLoading: false, + fileLanguage: 'text', editContent: '', commitMessage: 'Update via FlowDeck', commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'), @@ -182,8 +221,34 @@ function giteaWorkspace() { li.setAttribute('data-gitea-path', item.path); li.setAttribute('data-gitea-type', item.type === 'folder' ? 'folder' : 'file'); li.setAttribute('data-gitea-sha', item.sha || ''); - li.style.cssText = 'padding-left:12px;cursor:pointer;'; - li.innerHTML = '' + icon + '' + item.name + ''; + li.style.cssText = 'padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;'; + // Checkbox (hidden by default, visible in multi-select mode) + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'gitea-checkbox'; + cb.style.cssText = 'flex-shrink:0;margin:0;display:none;'; + cb.addEventListener('click', function(ev) { + ev.stopPropagation(); + var gw = document.querySelector('[x-data]').__x.$data; + gw.selectItem(item.path, li, ev); + }); + li.appendChild(cb); + // Icon + var iconSpan = document.createElement('span'); + iconSpan.className = 'page-icon'; + iconSpan.textContent = icon; + li.appendChild(iconSpan); + // Name (with double-click rename) + var nameSpan = document.createElement('span'); + nameSpan.className = 'page-name'; + nameSpan.textContent = item.name; + nameSpan.addEventListener('dblclick', function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + var gw = document.querySelector('[x-data]').__x.$data; + gw.startInlineRename(nameSpan, item.path, li); + }); + li.appendChild(nameSpan); container.appendChild(li); }); // Add Private Pages link @@ -209,16 +274,54 @@ function giteaWorkspace() { document.addEventListener('click', function(e) { var el = e.target.closest('.sidebar-item[data-gitea-path]'); if (!el) return; - e.preventDefault(); - e.stopPropagation(); + var path = el.getAttribute('data-gitea-path'); var type = el.getAttribute('data-gitea-type'); + + // If clicking the checkbox, let its own handler deal with selection + if (e.target.classList.contains('gitea-checkbox')) return; + + // If clicking the inline rename input, don't navigate + if (e.target.classList.contains('inline-rename-input')) return; + + // If Shift or Ctrl/Meta is pressed, use multi-selection + if (e.shiftKey || e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + self.selectItem(path, el, e); + return; + } + + // Normal click: navigate (open file/folder) + e.preventDefault(); + e.stopPropagation(); + + // Update visual "active" state + document.querySelectorAll('.sidebar-item.active').forEach(function(si) { + si.classList.remove('active'); + }); + el.classList.add('active'); + if (type === 'folder') { self.loadSubdir(path, el); } else { self.openFile(path, el.getAttribute('data-gitea-sha')); } }); + + // Listen for custom delete event on gitea sidebar items + document.addEventListener('gitea-delete', function(e) { + var el = e.target; + var path = el.getAttribute('data-gitea-path'); + if (!path) return; + if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return; + var sha = el.getAttribute('data-gitea-sha') || ''; + fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), { + method: 'DELETE', + }).then(function(r) { + if (r.ok) self.refreshTree(); + }).catch(function() {}); + }); }, async loadSubdir(path, el) { @@ -243,8 +346,33 @@ function giteaWorkspace() { li.setAttribute('data-gitea-path', child.path); li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file'); li.setAttribute('data-gitea-sha', child.sha || ''); - li.style.cursor = 'pointer'; - li.innerHTML = '' + icon + ' ' + child.name; + li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;'; + // Checkbox + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'gitea-checkbox'; + cb.style.cssText = 'flex-shrink:0;margin:0;display:none;'; + cb.addEventListener('click', function(ev) { + ev.stopPropagation(); + var gw = document.querySelector('[x-data]').__x.$data; + gw.selectItem(child.path, li, ev); + }); + li.appendChild(cb); + // Icon + var iconSpan = document.createElement('span'); + iconSpan.className = 'sidebar-icon'; + iconSpan.textContent = icon; + li.appendChild(iconSpan); + // Name + var nameSpan = document.createElement('span'); + nameSpan.textContent = child.name; + nameSpan.addEventListener('dblclick', function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + var gw = document.querySelector('[x-data]').__x.$data; + gw.startInlineRename(nameSpan, child.path, li); + }); + li.appendChild(nameSpan); ul.appendChild(li); }); el.appendChild(ul); @@ -256,6 +384,8 @@ function giteaWorkspace() { this.fileSha = sha || ''; this.showEditor = false; this.showFile = true; + this.fileLoading = true; + this.fileLanguage = this.getLanguage(path); try { var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path)); if (r.ok) { @@ -268,6 +398,30 @@ function giteaWorkspace() { } catch(e) { this.fileContent = '[Error loading file]'; } + this.fileLoading = false; + // Highlight after Alpine renders + var self = this; + this.$nextTick(function() { + var block = self.$refs.codeBlock; + if (block && block.textContent && typeof Prism !== 'undefined') { + Prism.highlightElement(block); + } + }); + }, + + getLanguage(path) { + var ext = (path || '').split('.').pop().toLowerCase(); + var map = { + js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript', + py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin', + c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp', + html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css', + json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini', + md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash', + php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart', + dockerfile:'docker', makefile:'makefile', cmake:'cmake', + }; + return map[ext] || 'text'; }, openEditor() { @@ -376,6 +530,54 @@ function giteaWorkspace() { } catch(e) {} }, + // Create new file + showNewFile: false, + newFilePath: '', + newFileContent: '', + newFileMsg: 'Create via FlowDeck', + async createNewFile() { + if (!this.newFilePath.trim()) return; + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', { + method: 'PUT', + headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()}, + body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'}) + }); + if (r.ok) { + this.showNewFile = false; + this.newFilePath = ''; + this.newFileContent = ''; + this.newFileMsg = 'Create via FlowDeck'; + await this.loadSidebarTree(); + } else { + var d = await r.json(); + alert('Failed: ' + (d.error || d.detail || 'Unknown error')); + } + } catch(e) { alert('Error: ' + e.message); } + }, + + // Upload files + triggerUpload() { document.getElementById('gitea-upload-input').click(); }, + async doUpload(ev) { + var files = ev.target.files; + if (!files.length) return; + for (var i = 0; i < files.length; i++) { + var form = new FormData(); + form.append('file', files[i]); + form.append('message', 'Upload ' + files[i].name + ' via FlowDeck'); + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', { + method: 'POST', + headers: {'X-CSRF-Token': this.getCsrfToken()}, + body: form + }); + if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); } + } catch(e) { console.error('Upload error:', e); } + } + await this.loadSidebarTree(); + ev.target.value = ''; + }, + async deletePrivate(id) { if (!confirm('Delete this private page?')) return; try { @@ -389,6 +591,144 @@ function giteaWorkspace() { getCsrfToken() { return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || ''; }, + + // ── Multi-selection ── + multiSelect: false, + selectedPaths: [], + lastClickedPath: null, + + toggleMultiSelect() { + this.multiSelect = !this.multiSelect; + var cbs = document.querySelectorAll('.gitea-checkbox'); + var self = this; + cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; }); + if (!this.multiSelect) { + // Clear selection when leaving multi-select mode + cbs.forEach(function(cb) { cb.checked = false; }); + this.selectedPaths = []; + this.lastClickedPath = null; + // Remove selected class + document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) { + el.classList.remove('gitea-selected'); + }); + } + }, + + selectItem(path, li, ev) { + if (ev.shiftKey && this.lastClickedPath) { + // Range select + var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]')); + var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this)); + var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this)); + if (startIdx >= 0 && endIdx >= 0) { + var lo = Math.min(startIdx, endIdx); + var hi = Math.max(startIdx, endIdx); + this.selectedPaths = []; + for (var i = lo; i <= hi; i++) { + this.selectedPaths.push(all[i].getAttribute('data-gitea-path')); + all[i].classList.add('gitea-selected'); + var cb = all[i].querySelector('.gitea-checkbox'); + if (cb) cb.checked = true; + } + } + this.multiSelect = true; + this.toggleMultiSelect(); // ensure checkboxes are visible + } else if (ev.ctrlKey || ev.metaKey) { + // Toggle single + var idx = this.selectedPaths.indexOf(path); + if (idx >= 0) { + this.selectedPaths.splice(idx, 1); + li.classList.remove('gitea-selected'); + var cb = li.querySelector('.gitea-checkbox'); + if (cb) cb.checked = false; + } else { + this.selectedPaths.push(path); + li.classList.add('gitea-selected'); + var cb = li.querySelector('.gitea-checkbox'); + if (cb) cb.checked = true; + } + this.multiSelect = this.selectedPaths.length > 0; + this.toggleMultiSelect(); + } else { + // Normal select — clear multi-selection + this.selectedPaths = [path]; + this.lastClickedPath = path; + document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) { + el.classList.remove('gitea-selected'); + }); + li.classList.add('gitea-selected', 'active'); + var cb = li.querySelector('.gitea-checkbox'); + if (cb) cb.checked = true; + } + }, + + isSelected(path) { + return this.selectedPaths.indexOf(path) >= 0; + }, + + // ── Inline rename ── + startInlineRename(nameSpan, path, li) { + var originalName = nameSpan.textContent; + var input = document.createElement('input'); + input.type = 'text'; + input.value = originalName; + input.className = 'inline-rename-input'; + input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;'; + nameSpan.replaceWith(input); + input.focus(); + input.select(); + var self = this; + var finish = function() { self.finishInlineRename(input, originalName, path, li); }; + var cancel = function() { + var span = document.createElement('span'); + span.className = 'page-name'; + span.textContent = originalName; + input.replaceWith(span); + }; + input.addEventListener('blur', finish); + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') finish(); + if (e.key === 'Escape') { e.stopPropagation(); cancel(); } + }); + }, + + finishInlineRename(input, originalName, path, li) { + if (input._renaming) return; // guard against double-fire + input._renaming = true; + var newName = input.value.trim(); + if (!newName || newName === originalName) { + var span = document.createElement('span'); + span.className = 'page-name'; + span.textContent = originalName; + input.replaceWith(span); + return; + } + var self = this; + // Construct new path by replacing the last segment + var parts = path.split('/'); + parts.pop(); + var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName; + fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()}, + body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName}) + }).then(function(r) { + if (r.ok) { + self.refreshTree(); + } else { + var span = document.createElement('span'); + span.className = 'page-name'; + span.textContent = originalName; + input.replaceWith(span); + alert('Rename failed'); + } + }).catch(function() { + var span = document.createElement('span'); + span.className = 'page-name'; + span.textContent = originalName; + input.replaceWith(span); + }); + }, }; } diff --git a/app/templates/workspaces.html b/app/templates/workspaces.html index 4ef1a2c..9db6003 100644 --- a/app/templates/workspaces.html +++ b/app/templates/workspaces.html @@ -69,6 +69,10 @@ .org-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);} .org-tab-badge{font-size:10px;padding:0 5px;border-radius:8px;background:rgba(255,255,255,.15);} .search-input:focus{border-color:var(--accent);} + +/* ── Skeleton loading animation ── */ +.skeleton{background:var(--bg-tertiary);animation:pulse 1.5s ease-in-out infinite;border-radius:4px;} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
@@ -109,8 +113,16 @@ 📝 Register with Gitea
- -
Loading Gitea projects…
+ +
+ +