feat: sidebar Repository identique au Workspace-local, pages forge unifiees, GC miroirs vides
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Successful in 25m11s
FlowDeck CI / docker (push) Successful in 1m32s

This commit is contained in:
2026-10-09 22:33:34 -04:00
parent 9ea0cddf3c
commit d95178156f
13 changed files with 1110 additions and 175 deletions
+154 -39
View File
@@ -5,8 +5,24 @@ var _regGiteaWs = () => {
const owner = params.get('owner') || '';
const repo = params.get('repo') || '';
// Forge is carried by the page route (/gitea-workspace vs /github-workspace);
// the sidebar cookie prefix follows it so refresh APIs resolve the mirror.
const forge = (window.location.pathname || '').indexOf('/github-workspace') === 0 ? 'github' : 'gitea';
return {
owner, repo,
owner, repo, forge,
apiBase() {
return '/api/' + this.forge + '/projects/' + this.owner + '/' + this.repo;
},
// ── Library-style toolbar (same as /local-workspace) ──
viewMode: localStorage.getItem('fd_default_view') || 'tree',
viewMenuOpen: false,
sortOpen: false,
filterOpen: false,
searchOpen: false,
sortBy: '',
filterType: '',
searchQuery: '',
showFile: false,
showEditor: false,
showPrivate: false,
@@ -27,6 +43,7 @@ var _regGiteaWs = () => {
// File tree browser
treeItems: [],
sortedTreeItems: [],
filteredTreeItems: [],
treeLoading: false,
folderStack: [],
currentPath: '',
@@ -34,17 +51,77 @@ var _regGiteaWs = () => {
gwCtx: { visible: false, x: 0, y: 0, item: null },
_sortTree() {
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
var items = [...this.treeItems];
var by = this.sortBy || '';
var typeRank = function(t) { return (t === 'folder' || t === 'dir') ? 0 : 1; };
items.sort(function(a, b) {
if (by === 'name-desc') return (b.name || '').localeCompare(a.name || '');
if (by === 'type') {
var d = typeRank(a.type) - typeRank(b.type);
return d !== 0 ? d : (a.name || '').localeCompare(b.name || '');
}
// default name-asc: folders first
var td = typeRank(a.type) - typeRank(b.type);
if (td !== 0) return td;
return (a.name || '').localeCompare(b.name || '');
});
this.sortedTreeItems = items;
this.doFilter();
},
toggleViewMenu() { this.viewMenuOpen = !this.viewMenuOpen; this.sortOpen = false; this.filterOpen = false; },
toggleSortMenu() { this.sortOpen = !this.sortOpen; this.viewMenuOpen = false; this.filterOpen = false; },
toggleFilterMenu() { this.filterOpen = !this.filterOpen; this.viewMenuOpen = false; this.sortOpen = false; },
toggleSearch() {
this.searchOpen = !this.searchOpen;
if (!this.searchOpen) { this.searchQuery = ''; this.doFilter(); }
},
doSort() { this._sortTree(); },
filterByType(t) { this.filterType = (this.filterType === t ? '' : t); this.doFilter(); },
doFilter() {
var q = (this.searchQuery || '').toLowerCase();
var ft = this.filterType || '';
this.filteredTreeItems = (this.sortedTreeItems || []).filter(function(it) {
if (ft === 'folder' && it.type !== 'folder') return false;
if (ft && ft !== 'folder') {
var ext = (it.name || '').split('.').pop().toLowerCase();
var kind = (/^(png|jpe?g|gif|svg|webp|ico|bmp)$/.test(ext)) ? 'image'
: (/^(pdf)$/.test(ext)) ? 'pdf'
: (/^(js|jsx|ts|tsx|py|rb|rs|go|java|kt|c|cpp|h|cs|php|swift|r|lua|dart|sh|sql)$/.test(ext)) ? 'code'
: (/^(md|txt|json|ya?ml|toml|ini|csv)$/.test(ext)) ? 'text'
: 'page';
if (kind !== ft && !(ft === 'page' && it.type === 'file' && kind === 'page')) {
// 'page' filter = generic files; keep kind match only
if (ft === 'page' && it.type !== 'file') return false;
else if (ft !== 'page') return false;
}
}
if (q && (it.name || '').toLowerCase().indexOf(q) < 0 && (it.path || '').toLowerCase().indexOf(q) < 0) return false;
return true;
});
},
createFolderAtRoot() {
var name = prompt('Folder name:');
if (!name || !name.trim()) return;
this.createNewFolder(name.trim(), this.currentPath || '');
},
async createNewFolder(name, basePath) {
var path = (basePath ? basePath.replace(/\/$/, '') + '/' : '') + name + '/.gitkeep';
try {
var r = await fetch(this.apiBase() + '/file', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ path: path, content: '', message: 'Create folder ' + name + ' via FlowDeck' })
});
if (r.ok) await this.refreshTree();
else window.showToast('Create folder failed', 'error');
} catch (e) { window.showToast('Create folder failed', 'error'); }
},
init() {
// Expose globally for header to access
window._gwData = this;
// Set cookie for sidebar
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
// Set cookie for sidebar (forge-prefixed so Workspace mirror resolves)
document.cookie = 'flowdeck_workspace=' + this.forge + ':' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
// Load sidebar tree (into gitea section)
this.loadSidebarTree();
// Load main content tree
@@ -57,14 +134,14 @@ var _regGiteaWs = () => {
this.treeLoading = true;
this.currentPath = path;
try {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
var url = this.apiBase() + '/tree';
if (path) url += '?path=' + encodeURIComponent(path);
var r = await fetch(url);
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; this.filteredTreeItems = []; return; }
var d = await r.json();
this.treeItems = d.tree || [];
this._sortTree();
} catch { this.treeItems = []; this.sortedTreeItems = []; }
} catch { this.treeItems = []; this.sortedTreeItems = []; this.filteredTreeItems = []; }
finally { this.treeLoading = false; }
},
@@ -97,7 +174,7 @@ var _regGiteaWs = () => {
var self = this;
var oldPath = item.path;
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
fetch(this.apiBase() + '/file', {
method: 'PUT',
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json' },
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
@@ -114,7 +191,7 @@ var _regGiteaWs = () => {
if (!item) return;
if (!confirm('Delete ' + item.name + '?')) return;
var self = this;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': getCsrf()},
fetch(this.apiBase() + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE'
}).then(function(r){
if (r.ok) { self.refreshTree(); }
@@ -124,7 +201,7 @@ var _regGiteaWs = () => {
async loadSidebarTree() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
var r = await fetch(this.apiBase() + '/tree');
if (!r.ok) {
// If API fails (e.g. no Gitea token), set a message
var container = document.getElementById('sidebar-gitea-items');
@@ -140,15 +217,31 @@ var _regGiteaWs = () => {
window.appState.sectionsOpen.gitea = true;
}
container.innerHTML = '';
// Build tree HTML as string and set once (more performant)
// Same DOM contract as Workspace-local (_workspace_tree_macro):
// .sidebar-item.ws-tree-item + chevron + .page-icon/.page-name.
var html = '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">' + icon + '</span>';
html += '<span class="page-name">' + item.name + '</span>';
html += '</li>';
var sorted = [...items].sort(function(a, b) {
var at = (a.type === 'folder' || a.type === 'dir') ? 0 : 1;
var bt = (b.type === 'folder' || b.type === 'dir') ? 0 : 1;
if (at !== bt) return at - bt;
return (a.name || '').localeCompare(b.name || '');
});
for (var i = 0; i < sorted.length; i++) {
var item = sorted[i];
var isF = item.type === 'folder' || item.type === 'dir';
var safe = (item.name || '').replace(/'/g, "\\'");
var chev = isF
? '<button class="tree-chevron" data-repo-toggle="' + item.path + '" title="Toggle folder">▶</button>'
: '<span style="width:12px;flex-shrink:0;"></span>';
var icon = isF ? getSvgIcon('folder',14) : getSvgIcon('file',14);
var label = isF
? '<span class="page-name tree-folder-link" title="Open folder">' + item.name + '</span>'
: '<span class="page-name">' + item.name + '</span>';
html += '<li class="sidebar-item ws-tree-item" data-gitea-path="' + item.path
+ '" data-gitea-type="' + (isF ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '')
+ '" data-is-folder="' + (isF ? 'true' : 'false') + '" data-depth="0" data-name="' + safe + '"'
+ ' style="padding-left:4px;">' + chev
+ '<span class="page-icon">' + icon + '</span>' + label + '</li>';
}
// Add Private Pages link
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
@@ -209,7 +302,7 @@ var _regGiteaWs = () => {
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'), {headers: {'X-CSRF-Token': getCsrf()},
fetch(self.apiBase() + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE',
}).then(function(r) {
if (r.ok) self.refreshTree();
@@ -226,21 +319,26 @@ var _regGiteaWs = () => {
return;
}
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
var r = await fetch(this.apiBase() + '/tree?path=' + encodeURIComponent(path));
if (!r.ok) return;
var d = await r.json();
var children = d.tree || [];
ul = document.createElement('ul');
ul.className = 'sidebar-items';
ul.style.paddingLeft = '20px';
ul.style.cssText = 'list-style:none;margin:0;padding:0;';
var depth = parseInt(el.getAttribute('data-depth') || '0', 10) + 1;
children.forEach(function(child) {
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
var isF = child.type === 'folder' || child.type === 'dir';
var icon = isF ? getSvgIcon('folder',14) : getSvgIcon('file',14);
var li = document.createElement('li');
li.className = 'sidebar-item';
li.className = 'sidebar-item ws-tree-item';
li.setAttribute('data-gitea-path', child.path);
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
li.setAttribute('data-gitea-type', isF ? 'folder' : 'file');
li.setAttribute('data-gitea-sha', child.sha || '');
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
li.setAttribute('data-is-folder', isF ? 'true' : 'false');
li.setAttribute('data-depth', String(depth));
li.setAttribute('data-name', (child.name || '').replace(/'/g, "\\'"));
li.style.cssText = 'padding-left:' + (4 + depth * 12) + 'px;';
// Checkbox
var cb = document.createElement('input');
cb.type = 'checkbox';
@@ -251,14 +349,29 @@ var _regGiteaWs = () => {
self.selectItem(child.path, li, ev);
});
li.appendChild(cb);
// Chevron (folders) — same as workspace-local
if (isF) {
var chev = document.createElement('button');
chev.className = 'tree-chevron';
chev.setAttribute('data-repo-toggle', child.path);
chev.title = 'Toggle folder';
chev.textContent = '▶';
li.appendChild(chev);
} else {
var sp = document.createElement('span');
sp.style.cssText = 'width:12px;flex-shrink:0;';
li.appendChild(sp);
}
// Icon
var iconSpan = document.createElement('span');
iconSpan.className = 'sidebar-icon';
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
iconSpan.className = 'page-icon';
iconSpan.innerHTML = icon;
li.appendChild(iconSpan);
// Name
var nameSpan = document.createElement('span');
nameSpan.className = 'page-name' + (isF ? ' tree-folder-link' : '');
nameSpan.textContent = child.name;
if (isF) nameSpan.title = 'Open folder';
nameSpan.addEventListener('dblclick', function(ev) {
ev.preventDefault();
ev.stopPropagation();
@@ -268,6 +381,8 @@ var _regGiteaWs = () => {
ul.appendChild(li);
});
el.appendChild(ul);
var chevBtn = el.querySelector('[data-repo-toggle]');
if (chevBtn) chevBtn.classList.add('open');
} catch { /* volontaire */ }
},
@@ -279,7 +394,7 @@ var _regGiteaWs = () => {
this.fileLoading = true;
this.fileLanguage = this.getLanguage(path);
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
var r = await fetch(this.apiBase() + '/file?path=' + encodeURIComponent(path));
if (r.ok) {
var d = await r.json();
this.fileContent = d.content || '';
@@ -334,7 +449,7 @@ var _regGiteaWs = () => {
async saveFile() {
if (!this.filePath) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
var r = await fetch(this.apiBase() + '/file', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({
@@ -361,7 +476,7 @@ var _regGiteaWs = () => {
if (!this.filePath) return;
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
var r = await fetch(this.apiBase() + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE',
});
if (r.ok) {
@@ -403,7 +518,7 @@ var _regGiteaWs = () => {
async loadPrivatePages() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
var r = await fetch(this.apiBase() + '/private-pages');
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
} catch { /* volontaire */ }
},
@@ -416,7 +531,7 @@ var _regGiteaWs = () => {
async openPrivate(id) {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
var r = await fetch(this.apiBase() + '/private-pages/' + id);
if (r.ok) {
var d = await r.json();
this.editingPrivate = id;
@@ -427,7 +542,7 @@ var _regGiteaWs = () => {
},
async savePrivate() {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
var url = this.apiBase() + '/private-pages';
var method = 'POST';
if (this.editingPrivate !== 'new') {
url += '/' + this.editingPrivate;
@@ -455,7 +570,7 @@ var _regGiteaWs = () => {
async createNewFile() {
if (!this.newFilePath.trim()) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
var r = await fetch(this.apiBase() + '/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'})
@@ -483,7 +598,7 @@ var _regGiteaWs = () => {
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', {
var r = await fetch(this.apiBase() + '/upload', {
method: 'POST',
headers: {'X-CSRF-Token': this.getCsrfToken()},
body: form
@@ -498,7 +613,7 @@ var _regGiteaWs = () => {
async deletePrivate(id) {
if (!confirm('Delete this private page?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
var r = await fetch(this.apiBase() + '/private-pages/' + id, {
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
});
if (r.ok) await this.loadPrivatePages();
@@ -625,7 +740,7 @@ var _regGiteaWs = () => {
var parts = path.split('/');
parts.pop();
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
fetch(this.apiBase() + '/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})
+10
View File
@@ -155,6 +155,16 @@ function workspacesPage() {
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
}
},
openGithub(proj) {
const full = proj.full_name || '';
const parts = full.split('/');
const owner = proj.owner?.login || parts[0] || '';
const repo = proj.name || parts[1] || '';
if (owner && repo) {
document.cookie = 'flowdeck_workspace=github:' + owner + ':' + repo + ';path=/;SameSite=Lax';
window.location = `/github-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
}
}
};
}