diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index 24dbc73..bc37fca 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -6,6 +6,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, const pending = new Map(); const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']); + function checklistOperation(value) { + const action = String(value?.action || ''); + if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null; + return { + action, + index:Number(value.index), + ...(action === 'rename' ? { label:String(value.label || '') } : {}), + }; + } + function reviewFingerprint(message) { return JSON.stringify({ body: String(message.body || ''), @@ -66,11 +76,13 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') && item.number === Number(message.number || 0)); if (queuedContent) { + const operation = checklistOperation(message.checklistOperation); const replacement = { ...queuedContent, operationId: String(requestedOperationId || makeId()).slice(0, 128), title: String(message.title || ''), body: String(message.body || ''), + ...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}), }; write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror); return replacement; @@ -115,6 +127,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, title: String(message.title || ''), baseBody: String(message.baseBody ?? message.body ?? ''), expectedUpdatedAt: String(message.expectedUpdatedAt || ''), + ...(checklistOperation(message.checklistOperation) ? { + checklistOperations:[checklistOperation(message.checklistOperation)], + } : {}), } : {}), }; items.push(item); diff --git a/frontend/checklist-conflict.js b/frontend/checklist-conflict.js index 5fc9a20..1313d1c 100644 --- a/frontend/checklist-conflict.js +++ b/frontend/checklist-conflict.js @@ -1,4 +1,4 @@ -function mergeChecklistConflict({ baseBody, localBody, remoteBody }) { +function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) { const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/; function tasks(body) { @@ -20,6 +20,70 @@ function mergeChecklistConflict({ baseBody, localBody, remoteBody }) { return result; } + function replayOperations(body, requested, reportChanges) { + let lines = String(body || '').split('\n'); + const visibleTasks = value => tasks(value).filter(entry => /^[-*+]/.test(entry.match[1])); + const logical = visibleTasks(baseBody).map(entry => ({ key:entry.key, label:entry.label })); + const replayed = []; + for (const operation of requested) { + const index = Number(operation?.index); + const target = logical[index]; + if (!target) return { body:null, changes:replayed, conflict:{ label:'Checklist step', reason:'missing' } }; + let entries = visibleTasks(lines.join('\n')); + const matches = entries.filter(entry => entry.key === target.key); + if (matches.length !== 1) { + return { body:null, changes:replayed, conflict:{ + label:target.label, reason:matches.length ? 'ambiguous' : 'missing', + } }; + } + const match = matches[0]; + if (operation.action === 'rename') { + const label = String(operation.label || '').trim().replace(/\s+/g, ' '); + const key = label.toLocaleLowerCase(); + if (!label || entries.some(entry => entry.key === key && entry.lineIndex !== match.lineIndex)) { + return { body:null, changes:replayed, conflict:{ label:target.label, reason:'ambiguous' } }; + } + lines[match.lineIndex] = match.match[1] + match.match[2] + match.match[3] + label; + if (reportChanges) replayed.push({ label:target.label, renamed:label }); + target.label = label; + target.key = key; + } else if (operation.action === 'remove') { + lines.splice(match.lineIndex, 1); + logical.splice(index, 1); + if (reportChanges) replayed.push({ label:target.label, removed:true }); + } else if (operation.action === 'move-earlier' || operation.action === 'move-later') { + const neighborIndex = operation.action === 'move-earlier' ? index - 1 : index + 1; + const neighbor = logical[neighborIndex]; + const neighborMatches = neighbor ? entries.filter(entry => entry.key === neighbor.key) : []; + if (neighborMatches.length !== 1 || Math.abs(neighborMatches[0].lineIndex - match.lineIndex) !== 1) { + return { body:null, changes:replayed, conflict:{ label:target.label, reason:'order-changed' } }; + } + const neighborLine = neighborMatches[0].lineIndex; + [lines[match.lineIndex], lines[neighborLine]] = [lines[neighborLine], lines[match.lineIndex]]; + [logical[index], logical[neighborIndex]] = [logical[neighborIndex], logical[index]]; + if (reportChanges) replayed.push({ + label:target.label, moved:operation.action === 'move-earlier' ? 'earlier' : 'later', + }); + } + } + return { body:lines.join('\n'), changes:replayed, conflict:null }; + } + + if (Array.isArray(operations) && operations.length) { + const baseReplay = replayOperations(baseBody, operations, false); + const remoteReplay = replayOperations(remoteBody, operations, true); + const conflict = baseReplay.conflict || remoteReplay.conflict; + if (conflict) return { body:null, changes:remoteReplay.changes || [], conflicts:[conflict] }; + const residual = mergeChecklistConflict({ + baseBody:baseReplay.body, localBody, remoteBody:remoteReplay.body, + }); + return { + body:residual.body, + changes:[...remoteReplay.changes, ...residual.changes], + conflicts:residual.conflicts, + }; + } + const base = grouped(tasks(baseBody)); const local = grouped(tasks(localBody)); const remoteEntries = tasks(remoteBody); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index be7065e..51bc6b1 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -474,6 +474,13 @@ textarea { resize: vertical; min-height: 120px; } .completed-filed-actions button { min-height:44px; min-width:0; } #issue-sheet:has(.completed-filed-actions:not([hidden])) .issue-sheet-panel { padding-bottom:calc(110px + env(safe-area-inset-bottom)); } .issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; } +.checklist-step-editor { display:grid; gap:8px; margin:10px 0 16px; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1526; } +.checklist-step-editor[hidden] { display:none; } +.checklist-step-editor input { width:100%; min-width:0; box-sizing:border-box; } +.checklist-step-editor-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } +.checklist-step-editor button, .checklist-step-editor input { min-height:44px; } +#remove-checklist-step { border-color:#b45309; } +#issue-sheet.read-only .checklist-step-editor { display:none; } .checklist-add { display:grid; gap:8px; margin:10px 0 16px; } .checklist-add form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; } .checklist-add form[hidden] { display:none; } @@ -653,7 +660,9 @@ textarea { resize: vertical; min-height: 120px; } .markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; } .markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; } .markdown-content .task-list-toggle { min-width:44px; min-height:44px; margin:-9px 0 -9px -9px; cursor:pointer; accent-color:#60a5fa; } +.markdown-content .task-list-manage { min-width:44px; min-height:44px; margin:-9px -9px -9px auto; padding:6px 8px; flex:0 0 auto; } .markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; } +.markdown-content.checklist-pending .task-list-manage { opacity:.65; cursor:wait; } .markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; } @media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } } .create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 7f12d9d..ed8d186 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -218,6 +218,7 @@ let selectedIssueOffline = false; let selectedIssueDetail = null; let dismissedChecklistBody = null; + let issueBlockerCandidates = []; let issueBlockerSearchTimer = null; let issueConversation = null; @@ -3505,6 +3506,8 @@ qs('#completed-filed-progress').textContent = item.is_completed ? 'Completed Filed issue ' + (completedPosition + 1) + ' of ' + completedItems.length : ''; qs('#issue-sheet-body').textContent = ''; + checklistStepManagement.reset(); + qs('#checklist-step-label').value = ''; qs('#open-add-checklist-step').disabled = true; qs('#add-checklist-step-form').hidden = true; qs('#add-checklist-step').value = ''; @@ -5672,6 +5675,14 @@ confirmed:applyIssueContent, restore:renderIssueBody, }); + const checklistStepManagement = issueController.bindTaskManagement({ + container:qs('#issue-sheet-body'), editor:qs('#checklist-step-editor'), label:qs('#checklist-step-label'), + earlier:qs('#move-checklist-step-earlier'), later:qs('#move-checklist-step-later'), + remove:qs('#remove-checklist-step'), cancel:qs('#cancel-checklist-step-edit'), + status:qs('#checklist-step-edit-status'), sheetStatus:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'), + current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}), + confirmed:applyIssueContent, + }); function closeAddChecklistStep() { qs('#add-checklist-step-form').hidden = true; qs('#add-checklist-step').value = ''; diff --git a/frontend/index.html b/frontend/index.html index fbda043..954c0c9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -605,6 +605,18 @@
+