3355 lines
156 KiB
JavaScript
3355 lines
156 KiB
JavaScript
(function(){
|
|
const qs = (s, el=document) => el.querySelector(s);
|
|
const fmt = (d) => new Date(d).toLocaleString();
|
|
const mobileComposerViewport = createMobileComposerViewport({
|
|
viewport: window.visualViewport,
|
|
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
|
entries: [
|
|
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
|
|
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
|
|
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
|
|
],
|
|
});
|
|
mobileComposerViewport.start();
|
|
[
|
|
[qs('.app-menu'), qs('#app-menu-toggle')],
|
|
[qs('.work-settings'), qs('#work-settings-toggle')],
|
|
].forEach(([disclosure, launcher]) => {
|
|
mobileLaunch.createDisclosure({disclosure, launcher}).start();
|
|
});
|
|
const PANEL_STATE_KEY = "stackchain.panel-state.v1";
|
|
const panels = Array.from(document.querySelectorAll('details[data-panel-key]'));
|
|
let savedState = {};
|
|
try {
|
|
savedState = JSON.parse(localStorage.getItem(PANEL_STATE_KEY) || '{}');
|
|
} catch (e) {
|
|
savedState = {};
|
|
}
|
|
panels.forEach((panel) => {
|
|
if (Object.prototype.hasOwnProperty.call(savedState, panel.dataset.panelKey)) {
|
|
panel.open = savedState[panel.dataset.panelKey];
|
|
}
|
|
panel.addEventListener('toggle', () => {
|
|
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
|
try {
|
|
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
|
} catch (e) {
|
|
console.warn('Could not persist panel state', e);
|
|
}
|
|
});
|
|
});
|
|
const mobileTaskButtons = Object.fromEntries(
|
|
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
|
|
);
|
|
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
|
function openMobileWork() {
|
|
const counts = countMyWork(activeMyWork);
|
|
const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');
|
|
qs('[data-work-filter="' + filter + '"]').click();
|
|
qs('#my-work').scrollIntoView({block:'start'});
|
|
qs('#my-work').focus();
|
|
}
|
|
const mobileTaskDock = createMobileTaskDock({
|
|
nav: qs('#mobile-task-dock'),
|
|
buttons: mobileTaskButtons,
|
|
attentionBadge: qs('#mobile-attention-count'),
|
|
overlays: mobileTaskOverlays,
|
|
actions: {
|
|
work: openMobileWork,
|
|
find: () => qs('#find-work').click(),
|
|
new: () => qs('#new-issue').click(),
|
|
search: () => qs('#open-palette').click(),
|
|
drafts: () => qs('[data-work-filter="draft"]').click(),
|
|
},
|
|
observe(callback, overlays) {
|
|
const observer = new MutationObserver(callback);
|
|
overlays.forEach(overlay => observer.observe(overlay, {attributes:true, attributeFilter:['class']}));
|
|
return observer;
|
|
},
|
|
});
|
|
mobileTaskDock.start();
|
|
const sourceDraftCount = qs('[data-work-count="draft"]');
|
|
const draftCount = qs('#mobile-draft-count');
|
|
function syncMobileDraftCount() {
|
|
draftCount.textContent = sourceDraftCount.textContent;
|
|
draftCount.setAttribute('aria-label', sourceDraftCount.textContent + ' drafts');
|
|
}
|
|
new MutationObserver(syncMobileDraftCount).observe(sourceDraftCount, {childList:true, characterData:true, subtree:true});
|
|
syncMobileDraftCount();
|
|
let liveMode = true;
|
|
let offlineWorkMode = false;
|
|
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
|
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
|
let selectedWorkFilter = 'all';
|
|
let selectedWorkMilestone = 'all';
|
|
let savedWorkFilter = null;
|
|
let launchFilterResolved = false;
|
|
try {
|
|
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
|
if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
|
selectedWorkFilter = savedFilter;
|
|
savedWorkFilter = savedFilter;
|
|
launchFilterResolved = true;
|
|
}
|
|
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
|
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
|
} catch (e) {
|
|
console.warn('Could not restore My Work filter', e);
|
|
}
|
|
let lastMyWork = [];
|
|
let lastDrafts = [];
|
|
let lastNotifications = [];
|
|
let lastContextSnapshot = null;
|
|
let notificationPagination = { page: 1, total: 0, has_more: false };
|
|
let workPagination = {};
|
|
let hasContextSnapshot = false;
|
|
let selectedReview = null;
|
|
let reviewTrigger = null;
|
|
let offlineReview = false;
|
|
let selectedUpdate = null;
|
|
let updateTrigger = null;
|
|
let selectedIssue = null;
|
|
let selectedIssueDetail = null;
|
|
let issueConversation = null;
|
|
let issueTrigger = null;
|
|
let selectedPull = null;
|
|
let pullTrigger = null;
|
|
let selectedPullDetail = null;
|
|
let pullConversation = null;
|
|
let pullReviewState = null;
|
|
let creatingIssue = false;
|
|
let findingWork = false;
|
|
let availablePagination = { page: 1, total: 0, has_more: false };
|
|
let progress = null;
|
|
let draft = null;
|
|
let reviewFiles = [];
|
|
let selectedReviewHead = '';
|
|
let activeInlineTarget = null;
|
|
let bulkConfirmationPending = false;
|
|
let bulkMarkPending = false;
|
|
let reviewHandoffPending = false;
|
|
let editingOutboxId = null;
|
|
let confirmedOwnerLogin = '';
|
|
let planningOwnerLogin = '';
|
|
let activeFlushLogin = '';
|
|
let activeMyWork = [];
|
|
let laterMyWork = [];
|
|
let todayMyWork = [];
|
|
const todayWork = createTodayWork({
|
|
storage: localStorage,
|
|
getLogin: () => planningOwnerLogin,
|
|
});
|
|
const todaySync = createTodaySync({
|
|
storage: localStorage,
|
|
getLogin: () => planningOwnerLogin,
|
|
fetchJson: fetchReviewJson,
|
|
onRemoteIds: ids => {
|
|
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
|
|
refreshMyWorkView();
|
|
warmTodayOffline();
|
|
},
|
|
onStatus: state => {
|
|
const status = qs('#today-sync-status');
|
|
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
|
(state === 'pending' ? 'Today saved on this device · sync pending.' :
|
|
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
|
'Today sync unavailable · changes stay on this device.'));
|
|
},
|
|
});
|
|
const laterWork = createLaterWork({
|
|
storage: localStorage,
|
|
getLogin: () => planningOwnerLogin,
|
|
onWake: () => {
|
|
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
|
|
refreshMyWorkView();
|
|
},
|
|
});
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) refreshMyWorkView();
|
|
});
|
|
|
|
async function fetchReviewJson(url, options) {
|
|
const response = await fetch(url, options);
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
|
error.status = response.status;
|
|
error.code = payload.detail?.code;
|
|
throw error;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function api(url) {
|
|
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const error = new Error(payload.error || payload.detail || 'Shared work request failed.');
|
|
error.unavailable = response.status === 404;
|
|
throw error;
|
|
}
|
|
return payload;
|
|
}
|
|
const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
|
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
|
const planningLoader = createIssueSheet.createPlanningLoader({
|
|
loadLabels: item => issueController.loadLabels(item),
|
|
loadMilestones: item => issueController.loadMilestones(item),
|
|
});
|
|
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
|
const unfiledCaptures = createUnfiledCaptures({
|
|
storage: localStorage,
|
|
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
|
getCurrentLogin: () => activeFlushLogin,
|
|
});
|
|
let backgroundIssueSync = null;
|
|
if ('indexedDB' in window) {
|
|
const backgroundIssueStore = createIssueSyncStore();
|
|
backgroundIssueSync = createBackgroundIssueSync({
|
|
store: backgroundIssueStore, fetchJson: fetchReviewJson,
|
|
});
|
|
backgroundIssueSync.requestSync = async () => {
|
|
if (!('serviceWorker' in navigator)) throw new Error('Background Sync unavailable');
|
|
const registration = await navigator.serviceWorker.ready;
|
|
if (!registration.sync) throw new Error('Background Sync unavailable');
|
|
await registration.sync.register('stackchain-issue-outbox-v1');
|
|
};
|
|
}
|
|
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
|
const issueOutbox = createIssueOutbox({
|
|
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
|
backgroundSync: backgroundIssueSync,
|
|
getOwnerLogin: () => confirmedOwnerLogin,
|
|
});
|
|
const authoredOutbox = createAuthoredOutbox({
|
|
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
|
backgroundSync: backgroundIssueSync,
|
|
getOwnerLogin: () => confirmedOwnerLogin,
|
|
});
|
|
const notificationReadOutbox = createNotificationReadOutbox({
|
|
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
|
backgroundSync: backgroundIssueSync,
|
|
getOwnerLogin: () => confirmedOwnerLogin,
|
|
});
|
|
if (backgroundIssueSync) {
|
|
backgroundIssueSync.snapshot().then(records => {
|
|
issueOutbox.reconcileBackground(records);
|
|
authoredOutbox.reconcileBackground(records);
|
|
notificationReadOutbox.reconcileBackground(records);
|
|
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
|
|
}
|
|
const shareParams = new URLSearchParams(location.search);
|
|
const sharedLaunch = {
|
|
title: shareParams.get('title') || '',
|
|
text: shareParams.get('text') || '',
|
|
url: shareParams.get('url') || '',
|
|
};
|
|
let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ?
|
|
issueCapture.stageSharedContent(sharedLaunch) : null;
|
|
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
|
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
|
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
|
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
|
|
function renderOfflineTodayStatus(status) {
|
|
const container = qs('#offline-today-readiness');
|
|
const label = qs('#offline-today-status');
|
|
const retry = qs('#retry-offline-today');
|
|
const visible = offlineWorkStore.enabled() && status.total > 0;
|
|
container.hidden = !visible;
|
|
if (!visible) return;
|
|
label.textContent = 'Today offline: ' + status.ready + ' of ' + status.total + ' ready' +
|
|
(status.pending ? ' · saving ' + status.pending : '') +
|
|
(status.failed ? ' · retry ' + status.failed : '');
|
|
retry.hidden = status.failed === 0;
|
|
}
|
|
const offlineToday = createOfflineToday({
|
|
loadDetail: item => item.is_review ? reviewController.load(item) :
|
|
item.kind === 'pull' ? pullController.load(item) : issueController.load(item),
|
|
loadSavedDetail: (login, item) => offlineWorkStore.loadDetail(login, item),
|
|
saveDetail: (login, item, detail) => offlineWorkStore.saveDetail(login, item, detail),
|
|
onStatus: renderOfflineTodayStatus,
|
|
});
|
|
function warmTodayOffline() {
|
|
if (!offlineWorkStore.enabled() || !confirmedOwnerLogin || offlineWorkMode) {
|
|
offlineToday.cancel();
|
|
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
|
return Promise.resolve();
|
|
}
|
|
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
|
|
}
|
|
const findWorkController = createFindWork({
|
|
fetchJson: fetchReviewJson,
|
|
onItems: renderAvailableIssues,
|
|
onPagination: pagination => {
|
|
availablePagination = pagination;
|
|
qs('#load-more-available').hidden = !pagination.has_more;
|
|
},
|
|
onStatus: message => { qs('#find-work-status').textContent = message; },
|
|
});
|
|
|
|
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
|
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
|
setClock(); setInterval(setClock, 1000);
|
|
|
|
async function fetchLiveSnapshot(revisions = {}, { signal } = {}) {
|
|
const params = new URLSearchParams();
|
|
Object.entries(revisions).forEach(([section, revision]) => {
|
|
if (Number.isInteger(revision) && revision >= 0) params.set(section + '_revision', revision);
|
|
});
|
|
const query = params.toString();
|
|
const res = await fetch('api/v1/live' + (query ? '?' + query : ''), {
|
|
headers: { Accept: 'application/json' },
|
|
signal,
|
|
});
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
async function markNotificationRead(notificationId) {
|
|
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/read', {
|
|
method: 'PATCH',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || 'Mark read failed.');
|
|
return payload;
|
|
}
|
|
|
|
async function markNotificationsRead(ids) {
|
|
const response = await fetch('api/v1/notifications/read', {
|
|
method: 'PATCH',
|
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.');
|
|
return payload;
|
|
}
|
|
|
|
async function fetchNotificationPage(page) {
|
|
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
|
|
return payload;
|
|
}
|
|
|
|
async function fetchNotificationDetail(notificationId) {
|
|
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || 'Loading the update failed.');
|
|
return payload;
|
|
}
|
|
|
|
async function fetchNotificationConversation(notificationId, page) {
|
|
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
|
|
'/conversation?page=' + encodeURIComponent(page) + '&limit=20', {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || 'Loading older messages failed.');
|
|
return payload;
|
|
}
|
|
|
|
async function fetchWorkPage(stream, page) {
|
|
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || 'Loading older work failed.');
|
|
return payload;
|
|
}
|
|
|
|
async function postNotificationReply(notificationId, body, operationId) {
|
|
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
|
'Idempotency-Key': operationId,
|
|
},
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.');
|
|
return payload;
|
|
}
|
|
|
|
const notificationAcknowledger = createNotificationAcknowledger({
|
|
markRead: markNotificationRead,
|
|
onItems: items => {
|
|
lastMyWork = items;
|
|
refreshMyWorkView();
|
|
},
|
|
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
|
});
|
|
const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({
|
|
markRead: markNotificationsRead,
|
|
onItems: items => {
|
|
lastMyWork = items;
|
|
refreshMyWorkView();
|
|
},
|
|
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
|
});
|
|
const notificationPager = createNotificationPager({
|
|
load: fetchNotificationPage,
|
|
onNotifications: items => {
|
|
lastNotifications = items;
|
|
if (lastContextSnapshot) {
|
|
lastContextSnapshot.notifications = lastNotifications;
|
|
paintMyWork(lastContextSnapshot);
|
|
}
|
|
},
|
|
onPagination: pagination => {
|
|
notificationPagination = pagination;
|
|
const loaded = Math.min(pagination.total, pagination.page * 50);
|
|
qs('#notification-page-status').textContent = pagination.total ?
|
|
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
|
qs('#load-more-notifications').hidden =
|
|
selectedWorkFilter !== 'update' || !pagination.has_more;
|
|
},
|
|
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
|
});
|
|
const workPager = createWorkPager({
|
|
load: fetchWorkPage,
|
|
onItems: (stream, items) => {
|
|
if (!lastContextSnapshot) return;
|
|
if (stream === 'issue') lastContextSnapshot.issues = items;
|
|
else lastContextSnapshot.pull_requests = items;
|
|
paintMyWork(lastContextSnapshot);
|
|
},
|
|
onPagination: pagination => {
|
|
workPagination = pagination;
|
|
updateWorkPaginationControls();
|
|
},
|
|
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
|
});
|
|
const notificationReplier = createNotificationReplier({
|
|
post: postNotificationReply,
|
|
storage: localStorage,
|
|
authoredOutbox,
|
|
onStatus: message => { qs('#update-reply-status').textContent = message; },
|
|
});
|
|
const updateOwnership = createUpdateOwnership({
|
|
claim: item => fetchReviewJson(
|
|
'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
|
'/issues/' + encodeURIComponent(item.number) + '/claim',
|
|
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
|
),
|
|
addToday: item => {
|
|
const result = todayWork.add(item);
|
|
refreshMyWorkView();
|
|
if (result === 'added') {
|
|
todaySync.enqueue('add', todayWork.identity(item));
|
|
todaySync.flush();
|
|
warmTodayOffline();
|
|
}
|
|
return result;
|
|
},
|
|
onClaimed: item => {
|
|
if (!lastContextSnapshot) return;
|
|
const issues = (lastContextSnapshot.issues || []).filter(candidate =>
|
|
candidate.repository !== item.repository || candidate.number !== item.number
|
|
);
|
|
lastContextSnapshot = { ...lastContextSnapshot, issues:issues.concat(item) };
|
|
paintMyWork(lastContextSnapshot);
|
|
},
|
|
onState: state => {
|
|
const button = qs('#update-ownership-action');
|
|
button.hidden = state.action === 'hidden';
|
|
button.disabled = state.busy;
|
|
button.textContent = state.action === 'today' ? 'Add to Today' : 'Take ownership';
|
|
if (state.message) qs('#update-sheet-status').textContent = state.message;
|
|
},
|
|
});
|
|
const notificationReader = createNotificationReader({
|
|
load: fetchNotificationDetail,
|
|
loadConversation: fetchNotificationConversation,
|
|
markRead: markNotificationRead,
|
|
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
|
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
|
|
onOpen: item => {
|
|
selectedUpdate = item;
|
|
qs('#update-sheet').classList.add('open');
|
|
qs('#update-sheet-key').textContent = item.key || '';
|
|
qs('#update-sheet-title').textContent = item.title || 'Unread update';
|
|
qs('#update-comments').textContent = '';
|
|
qs('#update-conversation-status').textContent = '';
|
|
qs('#load-older-update-comments').hidden = true;
|
|
qs('#update-subject-body').textContent = '';
|
|
qs('#update-subject-type').textContent = item.subject_type || 'Update';
|
|
qs('#update-subject-state').textContent = item.state || '';
|
|
qs('#open-update-gitea').href = item.url || '#';
|
|
qs('#update-reply').value = notificationReplier.loadDraft(item);
|
|
qs('#update-reply-status').textContent = '';
|
|
qs('#send-update-reply').disabled = false;
|
|
qs('#update-ownership-action').hidden = true;
|
|
qs('#retry-update-load').hidden = true;
|
|
setOfflineUpdateControls(false);
|
|
qs('#keep-update-unread').focus();
|
|
},
|
|
onDetail: detail => {
|
|
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
|
|
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
|
|
qs('#update-subject-state').textContent = detail.state || '';
|
|
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
|
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
|
if (offlineWorkMode) {
|
|
setOfflineUpdateControls(true);
|
|
} else {
|
|
updateOwnership.open(detail, selectedUpdate);
|
|
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
|
|
offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
|
|
}
|
|
}
|
|
qs('#retry-update-load').hidden = true;
|
|
},
|
|
onConversation: renderUpdateConversation,
|
|
onItems: items => {
|
|
const readId = selectedUpdate?.notification_id;
|
|
lastMyWork = items;
|
|
if (Number.isInteger(readId)) {
|
|
lastNotifications = lastNotifications.filter(item => item.id !== readId);
|
|
}
|
|
refreshMyWorkView();
|
|
},
|
|
onStatus: message => {
|
|
qs('#update-sheet-status').textContent = message;
|
|
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
|
|
if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message;
|
|
},
|
|
onClose: () => closeUpdateSheet(false),
|
|
});
|
|
|
|
function routedWorkItem(item) {
|
|
if (item?.has_update && item.kind === 'update') {
|
|
return { ...item, kind:'update' };
|
|
}
|
|
return { ...item, kind:item?.is_review ? 'review' : item?.kind };
|
|
}
|
|
|
|
function openRoutedWork(item, trigger, options = {}) {
|
|
if (!item) return;
|
|
if (offlineWorkMode) {
|
|
const offlineLogin = planningOwnerLogin || confirmedOwnerLogin ||
|
|
String(offlineWorkStore.load()?.user?.login || '').trim();
|
|
const savedDetail = offlineWorkStore.loadDetail(offlineLogin, item);
|
|
const savedUpdate = item.kind === 'update' && item.has_update;
|
|
if ((!savedUpdate && !todayWork.contains(item)) || !savedDetail) {
|
|
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
|
|
return;
|
|
}
|
|
if (item.kind === 'issue') {
|
|
issueTrigger = trigger;
|
|
openIssueSheet(item, trigger, savedDetail);
|
|
} else if (item.is_review) {
|
|
reviewTrigger = trigger;
|
|
openReviewSheet(item, trigger, savedDetail);
|
|
} else if (item.kind === 'pull') {
|
|
pullTrigger = trigger;
|
|
openPullSheet(item, trigger, savedDetail);
|
|
} else if (savedUpdate) {
|
|
updateTrigger = trigger;
|
|
notificationReader.open(item, savedDetail).then(opened => {
|
|
if (opened && selectedUpdate === item) {
|
|
qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) +
|
|
' · replies and read acknowledgements queue for sync. Reconnect to take ownership, defer, or load older messages.';
|
|
}
|
|
});
|
|
} else {
|
|
qs('#my-work-action-status').textContent = 'Details not saved—reconnect to open this item.';
|
|
}
|
|
return;
|
|
}
|
|
if (item.has_update && item.kind === 'update') updateTrigger = trigger;
|
|
else if (item.is_review) reviewTrigger = trigger;
|
|
else if (item.kind === 'issue') issueTrigger = trigger;
|
|
else if (item.kind === 'pull') pullTrigger = trigger;
|
|
workRoute.open(routedWorkItem(item), options);
|
|
}
|
|
|
|
const workRoute = createWorkRoute.createController({
|
|
location: window.location,
|
|
history: window.history,
|
|
eventTarget: window,
|
|
resolve: route => {
|
|
const params = new URLSearchParams({ kind:route.kind });
|
|
if (route.kind === 'update') params.set('notification_id', route.notification_id);
|
|
else {
|
|
params.set('repository', route.repository);
|
|
params.set('number', route.number);
|
|
}
|
|
return api('api/v1/work-route?' + params.toString());
|
|
},
|
|
onResolving: () => {
|
|
qs('#retry-work-route').hidden = true;
|
|
qs('#my-work-action-status').textContent = 'Loading shared work item…';
|
|
},
|
|
onOpen: item => {
|
|
qs('#retry-work-route').hidden = true;
|
|
qs('#my-work-action-status').textContent = '';
|
|
closeOpenWorkSheets();
|
|
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
|
|
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
|
|
else if (item.kind === 'issue') openIssueSheet(item, issueTrigger);
|
|
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
|
|
},
|
|
onClose: () => {
|
|
if (selectedIssue) closeIssueSheet(false);
|
|
if (selectedPull) closePullSheet(false);
|
|
if (selectedReview) closeReviewSheet(false);
|
|
if (selectedUpdate) closeUpdateSheet(true, false);
|
|
},
|
|
onInvalid: () => {
|
|
qs('#retry-work-route').hidden = true;
|
|
window.history.replaceState(null, '', window.location.pathname + window.location.search);
|
|
closeOpenWorkSheets();
|
|
qs('#my-work-action-status').textContent = 'Route unavailable · this item is no longer in My Work.';
|
|
qs('#my-work').scrollIntoView({ block:'start' });
|
|
},
|
|
onError: () => {
|
|
qs('#my-work-action-status').textContent = 'Could not load shared work item. The link is preserved; retry when connected.';
|
|
qs('#retry-work-route').hidden = false;
|
|
},
|
|
});
|
|
workRoute.start();
|
|
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
|
|
|
|
function closeOpenWorkSheets() {
|
|
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#update-sheet .update-sheet-panel']
|
|
.forEach(selector => mobileComposerViewport.close(qs(selector)));
|
|
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
|
|
qs(selector).classList.remove('open')
|
|
);
|
|
selectedIssue = null;
|
|
selectedIssueDetail = null;
|
|
selectedPull = null;
|
|
selectedPullDetail = null;
|
|
selectedReview = null;
|
|
selectedUpdate = null;
|
|
}
|
|
|
|
function openWorkSessionItem(item) {
|
|
openRoutedWork(item, null, { replace:true });
|
|
}
|
|
|
|
const workSession = createWorkSession({
|
|
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
|
|
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
|
|
getMilestone: () => selectedWorkMilestone,
|
|
onOpen: openWorkSessionItem,
|
|
onProgress: state => {
|
|
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
|
|
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
|
|
element.textContent = 'Item ' + state.index + ' of ' + state.total;
|
|
});
|
|
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
|
|
button.disabled = !state.can_previous;
|
|
});
|
|
document.querySelectorAll('[data-work-session-next]').forEach(button => {
|
|
button.textContent = state.can_next ? 'Next work item' : 'Finish session';
|
|
});
|
|
},
|
|
onFinish: () => {
|
|
closeOpenWorkSheets();
|
|
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
|
qs('#my-work-action-status').textContent = 'Work session complete.';
|
|
qs('#start-work-session').focus();
|
|
},
|
|
});
|
|
|
|
const detailDefer = createDetailDefer({
|
|
laterWork,
|
|
session: workSession,
|
|
close: () => workRoute.close(),
|
|
refresh: refreshMyWorkView,
|
|
focus: () => qs('[data-work-filter="' + selectedWorkFilter + '"]')?.focus(),
|
|
announce: message => { qs('#my-work-action-status').textContent = message; },
|
|
formatTime: fmt,
|
|
});
|
|
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
|
|
button.closest('.detail-defer').open = false;
|
|
detailDefer.defer(item, button.dataset.detailDeferPreset);
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const chooser = button.closest('.detail-defer');
|
|
chooser.open = false;
|
|
chooser.querySelector('summary')?.focus();
|
|
});
|
|
});
|
|
|
|
function updatePlanningAvailability() {
|
|
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
|
|
button.disabled = !planningOwnerLogin;
|
|
button.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
|
|
});
|
|
}
|
|
|
|
function setOfflineDetailControls(kind) {
|
|
const selectors = kind === 'issue' ? [
|
|
'#edit-issue-content', '#close-issue', '#release-issue', '#load-issue-handoff',
|
|
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
|
|
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
|
|
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
|
|
] : [
|
|
'#merge-pull', '#pull-review-retry', '#next-unreviewed-pull-file', '#load-older-pull-comments',
|
|
];
|
|
selectors.forEach(selector => {
|
|
const control = qs(selector);
|
|
if (control) control.disabled = true;
|
|
});
|
|
if (kind === 'issue') {
|
|
qs('#issue-planning').inert = true;
|
|
qs('#issue-handoff').inert = true;
|
|
} else {
|
|
qs('#pull-review').inert = true;
|
|
}
|
|
}
|
|
|
|
function setOfflineUpdateControls(offline) {
|
|
qs('#mark-update-read-next').disabled = false;
|
|
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
|
|
qs('#update-ownership-action').disabled = offline;
|
|
qs('#load-older-update-comments').disabled = offline;
|
|
qs('#update-sheet .detail-defer').inert = offline;
|
|
}
|
|
|
|
function renderContextSnapshot(data) {
|
|
liveMode = true;
|
|
hasContextSnapshot = true;
|
|
if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) {
|
|
const merge = (older, latest) => {
|
|
const byId = new Map((older || []).map(item => [item.id, item]));
|
|
(latest || []).forEach(item => {
|
|
const current = byId.get(item.id) || {};
|
|
const reasons = Array.from(new Set(
|
|
(current.work_reasons || []).concat(item.work_reasons || [])
|
|
));
|
|
byId.set(item.id, {
|
|
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
|
|
});
|
|
});
|
|
return Array.from(byId.values());
|
|
};
|
|
data.issues = merge(lastContextSnapshot.issues, data.issues);
|
|
data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests);
|
|
}
|
|
lastContextSnapshot = data;
|
|
if (data.work_pagination) workPager.reset(data.work_pagination);
|
|
if (data.error && lastMyWork.length) markMyWorkStale();
|
|
else paintMyWork(data);
|
|
openStagedSharedContent();
|
|
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
|
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
|
|
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
|
|
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
|
|
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
|
|
|
|
const issuesBox = qs('#issues-content');
|
|
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
|
|
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
|
|
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
|
|
|
|
const prsBox = qs('#prs-content');
|
|
const openPrs = (data.pull_requests || []).slice(0, 12);
|
|
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
|
|
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
|
|
|
|
paintDeltas(data.deltas || []);
|
|
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
|
|
renderRepoMix(qs('#repo-mix'), data);
|
|
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
|
|
setClock();
|
|
}
|
|
|
|
function handleContextError(e) {
|
|
console.error('context failed', e);
|
|
liveMode = false;
|
|
activeFlushLogin = '';
|
|
if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;
|
|
const timeoutStatus = e.name === 'TimeoutError' ?
|
|
'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot';
|
|
setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable');
|
|
if (!hasContextSnapshot) {
|
|
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
|
|
qs('#view-hint').textContent = 'Active view unavailable.';
|
|
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
|
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
|
paintDeltas([]);
|
|
}
|
|
markMyWorkStale();
|
|
}
|
|
|
|
function paintMyWork(data) {
|
|
lastMyWork = buildMyWork(data);
|
|
refreshMyWorkView();
|
|
}
|
|
|
|
function listDrafts() {
|
|
const unfiled = unfiledCaptures.list().map(item => ({
|
|
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
|
|
title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
|
updated_at:item.savedAt, quarantined:item.quarantined,
|
|
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
|
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
|
}));
|
|
return draftInbox.list().concat(unfiled).sort((left, right) =>
|
|
Number(right.updated_at || 0) - Number(left.updated_at || 0)
|
|
);
|
|
}
|
|
|
|
function refreshMyWorkView() {
|
|
lastDrafts = listDrafts();
|
|
const partitioned = laterWork.partition(lastMyWork, {
|
|
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
|
});
|
|
activeMyWork = partitioned.active;
|
|
laterMyWork = partitioned.later;
|
|
const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&
|
|
!lastContextSnapshot?.error &&
|
|
!Object.values(workPagination).some(page => page?.has_more);
|
|
todayMyWork = todayWork.reconcile(lastMyWork, {
|
|
pruneMissing: authoritativeTodayReconciliation,
|
|
onPrune: retiredIds => {
|
|
const queued = retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean);
|
|
if (queued) todaySync.flush();
|
|
return queued;
|
|
},
|
|
});
|
|
const counts = countMyWork(activeMyWork);
|
|
counts.today = todayMyWork.length;
|
|
counts.later = laterMyWork.length;
|
|
counts.draft = lastDrafts.length;
|
|
if (!launchFilterResolved) {
|
|
selectedWorkFilter = mobileLaunch.chooseFilter({
|
|
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
|
|
});
|
|
launchFilterResolved = true;
|
|
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
|
item.setAttribute('aria-pressed', String(item.dataset.workFilter === selectedWorkFilter))
|
|
);
|
|
}
|
|
Object.entries(counts).forEach(([filter, count]) => {
|
|
const element = qs('[data-work-count="' + filter + '"]');
|
|
if (element) element.textContent = count;
|
|
});
|
|
mobileTaskDock.updateAttention(counts.attention);
|
|
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
|
qs('#active-work-queue').textContent = activeQueue.firstChild.textContent.trim() +
|
|
' (' + (counts[selectedWorkFilter] || 0) + ')';
|
|
const milestoneSelect = qs('#work-milestone-filter');
|
|
const lanes = milestoneLanes(lastMyWork);
|
|
milestoneSelect.innerHTML = '<option value="all">All milestones</option>' +
|
|
'<option value="unplanned">Unplanned</option>' + lanes.map(lane =>
|
|
'<option value="' + Number(lane.id) + '">' + escapeHtml(lane.title) + '</option>'
|
|
).join('');
|
|
milestoneSelect.value = selectedWorkMilestone;
|
|
if (milestoneSelect.value !== selectedWorkMilestone) {
|
|
const retained = document.createElement('option');
|
|
retained.value = selectedWorkMilestone;
|
|
retained.textContent = 'Selected milestone';
|
|
milestoneSelect.append(retained);
|
|
milestoneSelect.value = selectedWorkMilestone;
|
|
}
|
|
qs('#my-work').removeAttribute('data-stale');
|
|
qs('#my-work-status').textContent = lastMyWork.length ?
|
|
summarizeMyWork(activeMyWork) + (laterMyWork.length ? ' · ' + laterMyWork.length + ' deferred' : '') :
|
|
'No assigned work, review requests, or unread updates.';
|
|
updateWorkPaginationControls();
|
|
renderMyWork();
|
|
if (workSession.active()) workSession.reconcile();
|
|
}
|
|
|
|
function activeWorkStreams() {
|
|
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
|
|
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
|
if (selectedWorkFilter === 'issue') return ['issue'];
|
|
if (selectedWorkFilter === 'pull') return ['pull'];
|
|
if (selectedWorkFilter === 'review') return ['review'];
|
|
if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review'];
|
|
return [];
|
|
}
|
|
|
|
function renderDrafts() {
|
|
const list = qs('#my-work-list');
|
|
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
|
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
|
const isUnfiled = item.kind === 'unfiled-issue';
|
|
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
|
|
const outboxActions = item.quarantined ?
|
|
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
|
item.kind === 'issue-outbox' ?
|
|
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
|
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
|
item.kind === 'authored-outbox' ?
|
|
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
|
|
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
|
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
|
|
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
|
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
|
const state = (isOutbox || isUnfiled) ?
|
|
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
|
(isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
|
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
|
return '<article class="my-work-card draft-card">' +
|
|
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
|
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
|
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state +
|
|
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
|
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
|
}).join('') : '<div class="muted">No unfinished drafts.</div>';
|
|
list.querySelectorAll('.draft-resume').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
|
if (!item) return;
|
|
if (item.kind === 'unfiled-issue') {
|
|
try {
|
|
const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
|
issueCapture.saveDraft(resumed);
|
|
refreshMyWorkView();
|
|
openCreateIssueSheet();
|
|
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
|
|
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
|
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
|
else if (item.route) workRoute.open(item.route);
|
|
});
|
|
});
|
|
list.querySelectorAll('.draft-edit').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
|
const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
|
if (!queued) return;
|
|
editingOutboxId = queued.id;
|
|
issueCapture.saveDraft(queued);
|
|
openCreateIssueSheet();
|
|
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
|
});
|
|
});
|
|
list.querySelectorAll('.draft-send').forEach(button => {
|
|
button.addEventListener('click', async () => {
|
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
|
if (!item?.outbox_id) return;
|
|
button.disabled = true;
|
|
if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin));
|
|
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
|
|
});
|
|
});
|
|
list.querySelectorAll('.draft-copy').forEach(button => {
|
|
button.addEventListener('click', async () => {
|
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
|
if (!item?.copy_text) return;
|
|
await navigator.clipboard.writeText(item.copy_text);
|
|
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
|
|
});
|
|
});
|
|
list.querySelectorAll('.draft-discard').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
if (!window.confirm('Discard this unfinished draft?')) return;
|
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
|
if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
|
|
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
|
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
|
else if (item) draftInbox.discard(item.id);
|
|
lastDrafts = listDrafts();
|
|
const count = qs('[data-work-count="draft"]');
|
|
if (count) count.textContent = lastDrafts.length;
|
|
renderDrafts();
|
|
qs('#my-work-action-status').textContent = 'Draft discarded.';
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateWorkPaginationControls() {
|
|
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
|
|
const streams = activeWorkStreams();
|
|
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
|
|
const summaries = streams.flatMap(stream => {
|
|
const page = workPagination[stream];
|
|
return page && page.total ?
|
|
[Math.min(page.total, page.page * 50) + ' of ' + page.total + ' ' + labels[stream]] : [];
|
|
});
|
|
qs('#work-page-status').textContent = summaries.join(' · ');
|
|
qs('#load-more-work').hidden = incomplete.length === 0;
|
|
qs('#load-more-work').textContent = incomplete.length ?
|
|
'Load older ' + labels[incomplete[0]] : 'Load older work';
|
|
}
|
|
|
|
function renderMyWork() {
|
|
lastDrafts = listDrafts();
|
|
const draftCount = qs('[data-work-count="draft"]');
|
|
if (draftCount) draftCount.textContent = lastDrafts.length;
|
|
if (selectedWorkFilter === 'draft') {
|
|
renderDrafts();
|
|
qs('#bulk-mark-read-bar').hidden = true;
|
|
qs('#load-more-notifications').hidden = true;
|
|
return;
|
|
}
|
|
const visible = selectedWorkFilter === 'today' ?
|
|
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
|
|
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
|
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
|
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
|
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
|
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
|
const routeItem = routedWorkItem(item);
|
|
const routeHref = createWorkRoute.serialize(routeItem);
|
|
const contents =
|
|
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
|
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
|
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
|
(item.milestone?.title ? ' <span class="pill milestone-badge">' + escapeHtml(item.milestone.title) + '</span>' : '') +
|
|
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
|
|
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
|
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
|
|
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
|
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
|
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
|
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
|
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
|
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
|
|
const laterActions = selectedWorkFilter === 'later' ?
|
|
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
|
|
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button></div>';
|
|
const alreadyToday = todayWork.contains(item);
|
|
const todayPosition = todayWork.position(item);
|
|
const todayActions = selectedWorkFilter === 'today' ?
|
|
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
|
|
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
|
|
const planningActions = todayActions + laterActions;
|
|
if (item.is_review) {
|
|
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
|
}
|
|
if (item.kind === 'issue') {
|
|
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
|
}
|
|
if (item.kind === 'pull') {
|
|
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
|
}
|
|
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
|
}).join('') : '<div class="muted">' + (incomplete ?
|
|
'More work is available. Load the next page.' :
|
|
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
|
document.querySelectorAll('[data-review-index]').forEach(button => {
|
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
|
});
|
|
document.querySelectorAll('[data-issue-index]').forEach(button => {
|
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.issueIndex)], button); });
|
|
});
|
|
document.querySelectorAll('[data-pull-index]').forEach(button => {
|
|
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.pullIndex)], button); });
|
|
});
|
|
document.querySelectorAll('[data-update-index]').forEach(button => {
|
|
button.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
const item = lastMyWork[Number(button.dataset.updateIndex)];
|
|
if (!item) return;
|
|
updateTrigger = button;
|
|
openRoutedWork(item, button);
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-notification-id]').forEach(button => {
|
|
button.addEventListener('click', async () => {
|
|
const notificationId = Number(button.dataset.notificationId);
|
|
button.disabled = true;
|
|
const acknowledged = await notificationAcknowledger.acknowledge(lastMyWork, notificationId);
|
|
if (acknowledged) {
|
|
lastNotifications = lastNotifications.filter(item => item.id !== notificationId);
|
|
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
|
} else {
|
|
document.querySelector('[data-notification-id="' + notificationId + '"]')?.focus();
|
|
}
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-later-preset]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
|
if (!item) return;
|
|
if (!planningOwnerLogin) {
|
|
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
|
return;
|
|
}
|
|
const until = laterWork.presetUntil(button.dataset.laterPreset);
|
|
const result = laterWork.defer(item, until);
|
|
qs('#my-work-action-status').textContent = result === 'deferred' ?
|
|
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
|
|
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
|
if (result !== 'deferred') return;
|
|
refreshMyWorkView();
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-later-restore]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
|
if (!item || !laterWork.restore(item)) return;
|
|
qs('#my-work-action-status').textContent = 'Work returned to its priority position.';
|
|
refreshMyWorkView();
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-today-add]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
if (!planningOwnerLogin) {
|
|
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
|
return;
|
|
}
|
|
const result = todayWork.add(lastMyWork[Number(button.dataset.workIndex)]);
|
|
qs('#my-work-action-status').textContent = result === 'full' ?
|
|
'Today is limited to 5 items. Remove one before adding more.' :
|
|
(result === 'added' ? 'Added to Today without changing Gitea.' :
|
|
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
|
|
refreshMyWorkView();
|
|
if (result === 'added') {
|
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
|
todaySync.enqueue('add', todayWork.identity(item));
|
|
todaySync.flush();
|
|
warmTodayOffline();
|
|
}
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-today-remove]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
|
if (todayWork.remove(item)) {
|
|
todaySync.enqueue('remove', todayWork.identity(item));
|
|
todaySync.flush();
|
|
}
|
|
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
|
|
refreshMyWorkView();
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-today-move]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
|
if (todayWork.move(item, button.dataset.todayMove)) {
|
|
todaySync.enqueue('move', todayWork.identity(item), button.dataset.todayMove);
|
|
todaySync.flush();
|
|
}
|
|
refreshMyWorkView();
|
|
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
|
|
});
|
|
});
|
|
const allIds = notificationIds(visible);
|
|
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
|
const bulkBar = qs('#bulk-mark-read-bar');
|
|
const bulkButton = qs('#bulk-mark-read');
|
|
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
|
|
qs('#load-more-notifications').hidden =
|
|
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
|
bulkButton.disabled = bulkMarkPending;
|
|
const bulkLabel = allIds.length > ids.length ?
|
|
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
|
|
'all ' + ids.length + ' updates';
|
|
bulkButton.textContent = bulkConfirmationPending ?
|
|
'Confirm marking ' + bulkLabel + ' read' :
|
|
'Mark ' + bulkLabel + ' read';
|
|
workRoute.setItems(lastMyWork);
|
|
}
|
|
|
|
function reviewFileElement(filename) {
|
|
return Array.from(document.querySelectorAll('.review-file')).find(
|
|
element => element.dataset.reviewFilename === filename
|
|
);
|
|
}
|
|
|
|
function showReviewProgress(snapshot) {
|
|
const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total;
|
|
qs('#review-progress').textContent = complete ?
|
|
'All files reviewed — finish in Gitea' :
|
|
(snapshot.newHead ? 'New commits detected · ' : '') + snapshot.reviewedCount + ' of ' + snapshot.total + ' files reviewed';
|
|
qs('#next-unreviewed-review').disabled = !snapshot.nextFilename;
|
|
document.querySelectorAll('.review-mark').forEach(button => {
|
|
const reviewed = snapshot.reviewed.includes(button.dataset.reviewFilename);
|
|
button.setAttribute('aria-pressed', String(reviewed));
|
|
button.textContent = reviewed ? 'Reviewed' : 'Mark reviewed';
|
|
button.closest('.review-file')?.classList.toggle('reviewed', reviewed);
|
|
});
|
|
}
|
|
|
|
function openNextUnreviewed(snapshot) {
|
|
if (!snapshot?.nextFilename) return;
|
|
const file = reviewFileElement(snapshot.nextFilename);
|
|
if (!file) return;
|
|
const toggle = file.querySelector('.review-file-toggle');
|
|
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
|
|
if (toggle && panel && toggle.getAttribute('aria-expanded') !== 'true') {
|
|
createReviewController.toggleDiff(toggle, panel);
|
|
}
|
|
file.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
toggle?.focus();
|
|
}
|
|
|
|
function renderIssueComment(comment) {
|
|
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
|
|
escapeHtml(comment.author || 'Unknown author') +
|
|
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
|
|
'</div><div class="issue-sheet-content markdown-content">' +
|
|
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
|
|
}
|
|
|
|
function renderIssueConversation(state) {
|
|
const comments = state?.comments || [];
|
|
qs('#issue-comments').innerHTML = comments.length ?
|
|
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
|
qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page);
|
|
qs('#issue-conversation-status').textContent = comments.length ?
|
|
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
|
}
|
|
|
|
function renderUpdateConversation(state) {
|
|
const comments = state?.comments || [];
|
|
qs('#update-comments').innerHTML = comments.length ?
|
|
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
|
qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page);
|
|
qs('#update-conversation-status').textContent = comments.length ?
|
|
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
|
}
|
|
|
|
function renderPullConversation(state) {
|
|
const comments = state?.comments || [];
|
|
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
|
|
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
|
|
).join('') : '<div class="muted">No comments yet.</div>';
|
|
qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page);
|
|
qs('#pull-conversation-status').textContent = comments.length ?
|
|
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
|
}
|
|
|
|
function renderIssueLabelEditor(item, confirmedNames, labels) {
|
|
const list = qs('#issue-label-list');
|
|
const status = qs('#issue-label-status');
|
|
list.textContent = '';
|
|
const draftIds = issueController.loadLabelDraft(item);
|
|
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
|
|
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
|
|
);
|
|
list.innerHTML = labels.map(label =>
|
|
'<label class="issue-label-option"><input type="checkbox" name="issue-label" value="' +
|
|
Number(label.id) + '"' + (selectedIds.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
|
escapeHtml(label.name) + '</span></label>'
|
|
).join('');
|
|
status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.';
|
|
qs('#save-issue-labels').disabled = false;
|
|
}
|
|
|
|
function selectedEditIssueLabelIds() {
|
|
return Array.from(document.querySelectorAll('input[name="issue-label"]:checked'))
|
|
.map(input => Number(input.value)).filter(Number.isInteger);
|
|
}
|
|
|
|
function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) {
|
|
const select = qs('#issue-milestone');
|
|
const status = qs('#issue-milestone-status');
|
|
select.innerHTML = '<option value="">No milestone</option>';
|
|
select.innerHTML += milestones.map(milestone =>
|
|
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
|
|
).join('');
|
|
const draft = issueController.loadMilestoneDraft(item);
|
|
select.value = String(draft ?? confirmedMilestone?.id ?? '');
|
|
select.disabled = false;
|
|
qs('#save-issue-milestone').disabled = false;
|
|
status.textContent = confirmedMilestone ?
|
|
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
|
|
}
|
|
|
|
async function loadIssuePlanning() {
|
|
const item = selectedIssue;
|
|
const detail = selectedIssueDetail;
|
|
if (!item || !detail) return;
|
|
qs('#retry-issue-planning').hidden = true;
|
|
qs('#issue-label-status').textContent = 'Loading labels…';
|
|
qs('#issue-milestone-status').textContent = 'Loading milestones…';
|
|
try {
|
|
const planning = await planningLoader.open(selectedIssue);
|
|
if (selectedIssue !== item) return;
|
|
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
|
|
renderIssueMilestoneEditor(item, detail.milestone, planning.milestones);
|
|
} catch (_error) {
|
|
if (selectedIssue !== item) return;
|
|
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
|
|
qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.';
|
|
qs('#retry-issue-planning').hidden = false;
|
|
}
|
|
}
|
|
|
|
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
|
if (!item) return;
|
|
qs('#issue-planning').inert = false;
|
|
qs('#issue-handoff').inert = false;
|
|
selectedIssue = item;
|
|
selectedIssueDetail = null;
|
|
issueConversation = null;
|
|
issueTrigger = trigger;
|
|
qs('#issue-sheet').classList.add('open');
|
|
qs('#issue-sheet-key').textContent = item.key || '';
|
|
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
|
|
qs('#issue-sheet-status').textContent = 'Loading issue…';
|
|
qs('#issue-sheet-body').textContent = '';
|
|
qs('#issue-labels').textContent = '';
|
|
qs('#issue-assignees').textContent = '';
|
|
qs('#issue-comments').textContent = '';
|
|
qs('#load-older-issue-comments').hidden = true;
|
|
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
|
|
qs('#issue-comment').value = issueController.loadDraft(item);
|
|
qs('#issue-comment-status').textContent = '';
|
|
qs('#issue-handoff').open = false;
|
|
qs('#issue-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
|
qs('#issue-handoff-recipient').disabled = true;
|
|
qs('#confirm-issue-handoff').disabled = true;
|
|
qs('#load-issue-handoff').disabled = false;
|
|
qs('#issue-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
|
qs('#issue-planning').open = false;
|
|
qs('#retry-issue-planning').hidden = true;
|
|
qs('#issue-label-list').textContent = '';
|
|
qs('#issue-label-status').textContent = 'Expand planning controls to load labels.';
|
|
qs('#save-issue-labels').disabled = true;
|
|
qs('#issue-due-date').value = '';
|
|
qs('#issue-due-date').disabled = true;
|
|
qs('#save-issue-due-date').disabled = true;
|
|
qs('#clear-issue-due-date').disabled = true;
|
|
qs('#issue-due-status').textContent = 'Loading due date…';
|
|
qs('#issue-milestone').innerHTML = '<option value="">No milestone</option>';
|
|
qs('#issue-milestone').disabled = true;
|
|
qs('#save-issue-milestone').disabled = true;
|
|
qs('#issue-milestone-status').textContent = 'Expand planning controls to load milestones.';
|
|
qs('#retry-issue-load').hidden = true;
|
|
qs('#open-issue-gitea').href = item.url || '#';
|
|
qs('#send-issue-comment').disabled = false;
|
|
qs('#edit-issue-content').disabled = true;
|
|
qs('#issue-edit-form').hidden = true;
|
|
qs('#issue-edit-status').textContent = '';
|
|
qs('#close-issue').disabled = false;
|
|
qs('#close-issue-sheet').focus();
|
|
try {
|
|
const detail = offlineDetail || await issueController.load(item);
|
|
if (selectedIssue !== item) return;
|
|
selectedIssueDetail = detail;
|
|
issueConversation = issueController.conversation(item, detail.conversation);
|
|
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
|
qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
|
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
|
'<span class="pill">' + escapeHtml(label) + '</span>'
|
|
).join(' ');
|
|
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
|
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
|
renderIssueConversation(issueConversation.snapshot());
|
|
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
|
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
|
qs('#edit-issue-content').disabled = false;
|
|
const dueDraft = issueController.loadDueDateDraft(item);
|
|
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
|
|
qs('#issue-due-date').disabled = false;
|
|
qs('#save-issue-due-date').disabled = false;
|
|
qs('#clear-issue-due-date').disabled = !detail.due_date;
|
|
qs('#issue-due-status').textContent = detail.due_date ?
|
|
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
|
|
if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();
|
|
if (offlineDetail) {
|
|
setOfflineDetailControls('issue');
|
|
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
|
|
' · comments queue for sync';
|
|
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
|
|
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
|
|
}
|
|
} catch (error) {
|
|
if (selectedIssue !== item) return;
|
|
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
|
qs('#retry-issue-load').hidden = false;
|
|
qs('#retry-issue-load').focus();
|
|
}
|
|
}
|
|
|
|
function closeIssueSheet(navigate = true) {
|
|
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
|
workRoute.close();
|
|
return;
|
|
}
|
|
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
|
|
qs('#issue-sheet').classList.remove('open');
|
|
selectedIssue = null;
|
|
selectedIssueDetail = null;
|
|
issueConversation = null;
|
|
if (issueTrigger?.isConnected) issueTrigger.focus();
|
|
}
|
|
|
|
function renderPullReview(detail, focusFilename = null) {
|
|
pullReviewState = pullController.reviewState(selectedPull, detail);
|
|
qs('#pull-review-progress').textContent = pullReviewState.total ?
|
|
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
|
|
qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete;
|
|
const eligibility = createPullSheet.mergeEligibility(detail, pullReviewState);
|
|
qs('#pull-merge-state').textContent = eligibility.reason;
|
|
qs('#merge-pull').disabled = !eligibility.allowed;
|
|
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
|
|
createPullSheet.renderFile(file, index, pullReviewState.reviewed.includes(file.filename), escapeHtml)
|
|
).join('') : '<div class="muted">No changed files reported.</div>';
|
|
qs('#pull-files').querySelectorAll('.pull-file-toggle').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const panel = document.getElementById(button.getAttribute('aria-controls'));
|
|
const expanded = button.getAttribute('aria-expanded') === 'true';
|
|
button.setAttribute('aria-expanded', String(!expanded));
|
|
if (panel) panel.hidden = expanded;
|
|
});
|
|
});
|
|
qs('#pull-files').querySelectorAll('.pull-review-file').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const filename = button.dataset.pullReviewFile;
|
|
pullReviewState = pullController.toggleReviewed(selectedPull, detail, filename);
|
|
renderPullReview(detail, filename);
|
|
});
|
|
});
|
|
if (focusFilename) {
|
|
Array.from(qs('#pull-files').querySelectorAll('.pull-review-file'))
|
|
.find(button => button.dataset.pullReviewFile === focusFilename)?.focus();
|
|
}
|
|
}
|
|
|
|
function focusNextUnreviewedPullFile() {
|
|
if (!selectedPullDetail || !pullReviewState) return;
|
|
const filename = pullController.nextUnreviewed(selectedPullDetail, pullReviewState);
|
|
const article = Array.from(qs('#pull-files').querySelectorAll('.pull-file'))
|
|
.find(file => file.dataset.pullFilename === filename);
|
|
const toggle = article?.querySelector('.pull-file-toggle');
|
|
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
|
|
if (toggle && panel) {
|
|
toggle.setAttribute('aria-expanded', 'true');
|
|
panel.hidden = false;
|
|
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
toggle.focus();
|
|
}
|
|
}
|
|
|
|
async function loadPullReview() {
|
|
if (!selectedPull || !selectedPullDetail?.head_sha) return;
|
|
const item = selectedPull;
|
|
const readDetail = selectedPullDetail;
|
|
qs('#pull-review-status').textContent = 'Loading changed files and merge readiness…';
|
|
qs('#pull-review-retry').hidden = true;
|
|
qs('#merge-pull').disabled = true;
|
|
try {
|
|
const review = await pullController.loadReview(item, readDetail.head_sha);
|
|
if (selectedPull !== item) return;
|
|
selectedPullDetail = { ...readDetail, ...review };
|
|
qs('#pull-ci-state').textContent = 'CI ' + (review.ci_state || 'unknown');
|
|
renderPullReview(selectedPullDetail);
|
|
qs('#pull-review-status').textContent = review.head_sha === readDetail.head_sha ?
|
|
'Review data ready for the current head.' : 'New commits detected. Review progress restarted for the latest head.';
|
|
} catch (error) {
|
|
if (selectedPull !== item) return;
|
|
qs('#pull-review-status').textContent = error.message + ' Reading and replies remain available; retry here.';
|
|
qs('#pull-review-retry').hidden = false;
|
|
qs('#pull-merge-state').textContent = 'Review data unavailable';
|
|
qs('#merge-pull').disabled = true;
|
|
}
|
|
}
|
|
|
|
async function openPullSheet(item, trigger, offlineDetail = null) {
|
|
if (!item) return;
|
|
qs('#pull-review').inert = false;
|
|
selectedPull = item;
|
|
pullTrigger = trigger;
|
|
selectedPullDetail = null;
|
|
pullConversation = null;
|
|
pullReviewState = null;
|
|
qs('#pull-sheet').classList.add('open');
|
|
qs('#pull-sheet-key').textContent = item.key || '';
|
|
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
|
|
qs('#pull-sheet-status').textContent = 'Loading pull request…';
|
|
qs('#pull-sheet-body').textContent = '';
|
|
qs('#pull-files').textContent = '';
|
|
qs('#pull-review').open = false;
|
|
qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.';
|
|
qs('#pull-review-retry').hidden = true;
|
|
qs('#pull-review-progress').textContent = 'Review progress unavailable.';
|
|
qs('#next-unreviewed-pull-file').disabled = true;
|
|
qs('#pull-comments').textContent = '';
|
|
qs('#load-older-pull-comments').hidden = true;
|
|
qs('#pull-conversation-status').textContent = 'Loading newest messages…';
|
|
qs('#pull-comment').value = pullController.loadDraft(item);
|
|
qs('#pull-comment-status').textContent = '';
|
|
qs('#pull-ci-state').textContent = 'CI unknown';
|
|
qs('#pull-merge-state').textContent = 'Review data not loaded';
|
|
qs('#merge-pull').disabled = true;
|
|
qs('#retry-pull-load').hidden = true;
|
|
qs('#open-pull-gitea').href = item.url || '#';
|
|
qs('#close-pull-sheet').focus();
|
|
try {
|
|
const detail = offlineDetail || await pullController.load(item);
|
|
if (selectedPull !== item) return;
|
|
selectedPullDetail = detail;
|
|
pullConversation = pullController.conversation(item, detail.conversation);
|
|
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
|
|
qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
|
renderPullConversation(pullConversation.snapshot());
|
|
qs('#open-pull-gitea').href = detail.url || item.url || '#';
|
|
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
|
|
if (offlineDetail) {
|
|
setOfflineDetailControls('pull');
|
|
qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
|
|
' · comments queue for sync';
|
|
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
|
|
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
|
|
}
|
|
} catch (error) {
|
|
if (selectedPull !== item) return;
|
|
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
|
qs('#retry-pull-load').hidden = false;
|
|
qs('#retry-pull-load').focus();
|
|
}
|
|
}
|
|
|
|
function closePullSheet(navigate = true) {
|
|
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
|
workRoute.close();
|
|
return;
|
|
}
|
|
mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel'));
|
|
qs('#pull-sheet').classList.remove('open');
|
|
selectedPull = null;
|
|
selectedPullDetail = null;
|
|
pullConversation = null;
|
|
pullReviewState = null;
|
|
if (pullTrigger?.isConnected) pullTrigger.focus();
|
|
}
|
|
|
|
function selectedIssueLabelIds() {
|
|
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
|
|
.map(input => Number(input.value)).filter(Number.isInteger);
|
|
}
|
|
|
|
function renderAvailableIssues(items) {
|
|
const list = qs('#find-work-list');
|
|
list.innerHTML = items.length ? items.map((item, index) => {
|
|
const expanded = findWorkController.isPreviewed(item);
|
|
const detailId = 'find-work-detail-' + index;
|
|
const detail = '<div id="' + detailId + '" class="find-work-detail"' + (expanded ? '' : ' hidden') +
|
|
'><div class="find-work-description markdown-content">' + renderMarkdown(item.body || 'No description provided.') + '</div>' +
|
|
(item.url ? '<a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener noreferrer">Open in Gitea</a>' : '') +
|
|
'</div>';
|
|
return '<article class="find-work-card"><div class="small">' + escapeHtml(item.repository) + '#' +
|
|
Number(item.number) + '</div><strong>' + escapeHtml(item.title || 'Untitled issue') + '</strong>' +
|
|
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
|
|
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
|
|
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
|
|
detail + '<button type="button" data-claim-index="' + index + '">Assign to me</button></article>';
|
|
}).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
|
|
list.querySelectorAll('[data-preview-index]').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const item = findWorkController.items()[Number(button.dataset.previewIndex)];
|
|
if (!item) return;
|
|
const expanded = findWorkController.togglePreview(item);
|
|
const detail = document.getElementById(button.getAttribute('aria-controls'));
|
|
button.setAttribute('aria-expanded', String(expanded));
|
|
button.textContent = expanded ? 'Hide details' : 'View details';
|
|
if (detail) detail.hidden = !expanded;
|
|
});
|
|
});
|
|
list.querySelectorAll('[data-claim-index]').forEach(button => {
|
|
button.addEventListener('click', async () => {
|
|
const item = findWorkController.items()[Number(button.dataset.claimIndex)];
|
|
if (!item) return;
|
|
button.disabled = true;
|
|
try {
|
|
const confirmed = await findWorkController.claim(item);
|
|
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
|
|
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
|
lastMyWork = buildMyWork(lastContextSnapshot);
|
|
const claimed = lastMyWork.find(work =>
|
|
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
|
|
);
|
|
taskOverlayHistory.leave();
|
|
refreshMyWorkView();
|
|
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
|
|
openRoutedWork(claimed, qs('#find-work'));
|
|
} catch (error) {
|
|
qs('#find-work-status').textContent = error.message + ' Refresh and retry.';
|
|
button.disabled = false;
|
|
button.focus();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function openFindWorkSheet(navigate = true) {
|
|
if (navigate) {
|
|
taskOverlayHistory.open('find');
|
|
return;
|
|
}
|
|
findingWork = true;
|
|
qs('#find-work-sheet').classList.add('open');
|
|
const retainedItems = findWorkController.items();
|
|
if (retainedItems.length) renderAvailableIssues(retainedItems);
|
|
else qs('#find-work-list').textContent = '';
|
|
qs('#find-work-status').textContent = retainedItems.length ?
|
|
'Refreshing available issues…' : 'Loading available issues…';
|
|
qs('#load-more-available').hidden = true;
|
|
qs('#close-find-work').focus();
|
|
try {
|
|
const result = await findWorkController.load();
|
|
if (!result?.stale) {
|
|
qs('#find-work-status').textContent = findWorkController.items().length ?
|
|
findWorkController.items().length + ' of ' + availablePagination.total + ' available issues loaded.' :
|
|
'No unassigned issues are available.';
|
|
}
|
|
} catch (error) {
|
|
qs('#find-work-status').textContent = error.message + ' Close and retry.';
|
|
}
|
|
}
|
|
|
|
function closeFindWorkSheet(navigate = true) {
|
|
if (navigate && taskOverlayHistory.current() === 'find') {
|
|
taskOverlayHistory.close();
|
|
return;
|
|
}
|
|
findingWork = false;
|
|
qs('#find-work-sheet').classList.remove('open');
|
|
qs('#find-work').focus();
|
|
}
|
|
|
|
function saveIssueCaptureDraft() {
|
|
issueCapture.saveDraft({
|
|
repository: qs('#create-issue-repository').value,
|
|
title: qs('#create-issue-title').value,
|
|
body: qs('#create-issue-body').value,
|
|
labelIds: selectedIssueLabelIds(),
|
|
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
|
dueDate: qs('#create-issue-due-date').value,
|
|
});
|
|
}
|
|
|
|
async function loadIssueLabels(repository, selectedIds = []) {
|
|
const list = qs('#create-issue-label-list');
|
|
const status = qs('#create-issue-label-status');
|
|
list.innerHTML = '';
|
|
if (!repository) {
|
|
status.textContent = 'Choose a repository to load labels.';
|
|
return;
|
|
}
|
|
status.textContent = 'Loading labels…';
|
|
try {
|
|
const labels = await issueCapture.loadLabels(repository);
|
|
const selected = new Set(selectedIds.map(Number));
|
|
list.innerHTML = labels.map(label =>
|
|
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
|
|
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
|
escapeHtml(label.name) + '</span></label>'
|
|
).join('');
|
|
status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.';
|
|
} catch (error) {
|
|
status.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
|
|
}
|
|
}
|
|
|
|
async function loadIssueMilestones(repository, selectedId = null) {
|
|
const select = qs('#create-issue-milestone');
|
|
const status = qs('#create-issue-milestone-status');
|
|
select.innerHTML = '<option value="">No milestone</option>';
|
|
if (!repository) {
|
|
status.textContent = 'Choose a repository to load milestones.';
|
|
return;
|
|
}
|
|
status.textContent = 'Loading milestones…';
|
|
try {
|
|
const milestones = await issueCapture.loadMilestones(repository);
|
|
if (qs('#create-issue-repository').value !== repository) return;
|
|
select.innerHTML += milestones.map(milestone =>
|
|
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
|
|
).join('');
|
|
if (selectedId) select.value = String(selectedId);
|
|
status.textContent = milestones.length ?
|
|
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
|
|
} catch (_error) {
|
|
if (qs('#create-issue-repository').value !== repository) return;
|
|
status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
|
|
}
|
|
}
|
|
|
|
function clearSharedLaunchUrl() {
|
|
const cleanUrl = location.pathname + location.hash;
|
|
history.replaceState(history.state || {}, '', cleanUrl);
|
|
}
|
|
|
|
function openStagedSharedContent() {
|
|
if (!sharedLaunchState || sharedLaunchState.status === 'shown') return;
|
|
openCreateIssueSheet();
|
|
if (sharedLaunchState.status === 'conflict') {
|
|
qs('#shared-content-conflict').hidden = false;
|
|
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
|
|
qs('#resume-issue-draft').focus();
|
|
sharedLaunchState = {status: 'shown'};
|
|
return;
|
|
}
|
|
clearSharedLaunchUrl();
|
|
sharedLaunchState = null;
|
|
}
|
|
|
|
function openCreateIssueSheet(navigate = true) {
|
|
if (navigate) {
|
|
taskOverlayHistory.open('new');
|
|
return;
|
|
}
|
|
const captureDraft = issueCapture.loadDraft();
|
|
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
|
if (captureDraft.repository && !repositories.includes(captureDraft.repository)) {
|
|
repositories.unshift(captureDraft.repository);
|
|
}
|
|
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
|
|
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
|
|
).join('');
|
|
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
|
|
qs('#create-issue-title').value = captureDraft.title;
|
|
qs('#create-issue-body').value = captureDraft.body;
|
|
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
|
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
|
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
|
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
|
|
qs('#submit-new-issue').disabled = !repositories.length;
|
|
qs('#create-issue-sheet').classList.add('open');
|
|
creatingIssue = true;
|
|
qs('#create-issue-title').focus();
|
|
}
|
|
|
|
let suppressCreateDraftOnHistoryClose = false;
|
|
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
|
if (navigate && taskOverlayHistory.current() === 'new') {
|
|
suppressCreateDraftOnHistoryClose = !preserveDraft;
|
|
taskOverlayHistory.close();
|
|
return;
|
|
}
|
|
qs('#create-issue-sheet').classList.remove('open');
|
|
creatingIssue = false;
|
|
qs('#new-issue').focus();
|
|
}
|
|
|
|
function applyOutboxResult(result, openCreated = false) {
|
|
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
|
(result.confirmed || []).forEach(confirmed => {
|
|
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
|
});
|
|
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
|
|
refreshMyWorkView();
|
|
if (result.confirmed?.length) {
|
|
const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
|
|
qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
|
|
const confirmed = result.confirmed[result.confirmed.length - 1];
|
|
const created = lastMyWork.find(item =>
|
|
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
|
);
|
|
if (openCreated && created) openRoutedWork(created, qs('#new-issue'));
|
|
} else if ((result.remaining || []).some(item => item.status === 'attention')) {
|
|
qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
|
|
} else {
|
|
qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.';
|
|
}
|
|
}
|
|
|
|
async function flushIssueOutbox() {
|
|
if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return;
|
|
applyOutboxResult(await issueOutbox.flush(activeFlushLogin));
|
|
}
|
|
|
|
function applyAuthoredOutboxResult(result) {
|
|
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
|
refreshMyWorkView();
|
|
const attention = (result.remaining || []).some(item => item.status === 'attention');
|
|
qs('#my-work-action-status').textContent = result.confirmed?.length ?
|
|
result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' :
|
|
attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' :
|
|
'Message queued for sync when the connection returns.';
|
|
}
|
|
|
|
async function flushAuthoredOutbox() {
|
|
if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return;
|
|
applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin));
|
|
}
|
|
|
|
async function flushNotificationReadOutbox() {
|
|
if (!navigator.onLine || !activeFlushLogin || !notificationReadOutbox.list().length) return;
|
|
const result = await notificationReadOutbox.flush(activeFlushLogin);
|
|
if (result.confirmed?.length) {
|
|
qs('#my-work-action-status').textContent = result.confirmed.length +
|
|
' queued update' + (result.confirmed.length === 1 ? '' : 's') + ' marked read.';
|
|
}
|
|
}
|
|
|
|
function canQueueMessage(error) {
|
|
const status = Number(error?.status || 0);
|
|
return !status || status >= 500;
|
|
}
|
|
|
|
function inlineAnchor(target) {
|
|
return {
|
|
path: target.dataset.reviewFilename,
|
|
...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}),
|
|
...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}),
|
|
};
|
|
}
|
|
|
|
function sameInlineAnchor(comment, anchor) {
|
|
return comment.path === anchor.path && comment.new_position === anchor.new_position &&
|
|
comment.old_position === anchor.old_position;
|
|
}
|
|
|
|
function closeInlineComposer() {
|
|
qs('#review-inline-composer').hidden = true;
|
|
qs('#review-inline-body').value = '';
|
|
activeInlineTarget = null;
|
|
}
|
|
|
|
function openInlineComposer(target) {
|
|
if (!draft) return;
|
|
activeInlineTarget = target;
|
|
const anchor = inlineAnchor(target);
|
|
const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor));
|
|
qs('#review-inline-location').textContent = anchor.path + ' · line ' +
|
|
(anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)');
|
|
qs('#review-inline-body').value = existing?.body || '';
|
|
qs('#delete-inline-comment').disabled = !existing;
|
|
qs('#review-inline-composer').hidden = false;
|
|
qs('#review-inline-body').focus();
|
|
}
|
|
|
|
async function openReviewSheet(item, trigger, cachedDetail = null) {
|
|
selectedReview = item;
|
|
reviewTrigger = trigger;
|
|
offlineReview = Boolean(cachedDetail);
|
|
qs('#review-sheet').classList.add('open');
|
|
qs('#review-sheet-key').textContent = item.key;
|
|
qs('#review-sheet-title').textContent = item.title;
|
|
qs('#review-sheet-status').textContent = 'Loading review details…';
|
|
qs('#retry-review-load').hidden = true;
|
|
qs('#review-sheet-body').textContent = '';
|
|
qs('#review-files').textContent = '';
|
|
qs('#review-progress').textContent = '0 of 0 files reviewed';
|
|
qs('#next-unreviewed-review').disabled = true;
|
|
qs('#review-history').textContent = '';
|
|
qs('#open-review-gitea').href = item.url;
|
|
qs('#review-decision').value = 'comment';
|
|
qs('#review-summary').value = '';
|
|
qs('#review-copy-fallback').hidden = true;
|
|
qs('#review-copy-fallback').value = '';
|
|
qs('#review-handoff-link').hidden = true;
|
|
qs('#review-handoff-link').href = item.url;
|
|
qs('#review-handoff-status').textContent = '';
|
|
qs('#review-submit-status').textContent = '';
|
|
qs('#continue-review-to-merge').hidden = true;
|
|
qs('#submit-review').disabled = true;
|
|
qs('#submit-review').textContent = offlineReview ? 'Reconnect to validate & submit' : 'Submit review';
|
|
closeInlineComposer();
|
|
draft = null;
|
|
reviewFiles = [];
|
|
selectedReviewHead = '';
|
|
qs('#close-review-sheet').focus();
|
|
try {
|
|
const detail = cachedDetail || await reviewController.load(selectedReview);
|
|
if (selectedReview !== item) return;
|
|
qs('#review-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
|
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
|
|
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
|
|
createReviewController.renderDiffFile(file, index, escapeHtml)
|
|
).join('') : '<div>No changed files reported.</div>';
|
|
document.querySelectorAll('.review-file-toggle').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const panel = document.getElementById(button.getAttribute('aria-controls'));
|
|
if (panel) createReviewController.toggleDiff(button, panel);
|
|
});
|
|
});
|
|
progress = createReviewController.createProgress({
|
|
storage: localStorage,
|
|
repository: item.repository,
|
|
number: item.number,
|
|
headSha: detail.head_sha || 'unknown',
|
|
files: detail.files || [],
|
|
});
|
|
reviewFiles = detail.files || [];
|
|
selectedReviewHead = detail.head_sha || '';
|
|
draft = createReviewController.createDraft({
|
|
storage: localStorage,
|
|
repository: item.repository,
|
|
number: item.number,
|
|
headSha: detail.head_sha || 'unknown',
|
|
files: reviewFiles,
|
|
});
|
|
const draftSnapshot = draft.snapshot();
|
|
qs('#review-decision').value = draftSnapshot.decision;
|
|
qs('#review-summary').value = draftSnapshot.summary;
|
|
document.querySelectorAll('.review-note').forEach(note => {
|
|
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
|
|
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
|
|
});
|
|
document.querySelectorAll('.review-inline-target').forEach(target => {
|
|
const anchor = inlineAnchor(target);
|
|
target.classList.toggle('has-draft', draftSnapshot.comments.some(comment =>
|
|
sameInlineAnchor(comment, anchor)
|
|
));
|
|
target.addEventListener('click', () => openInlineComposer(target));
|
|
});
|
|
document.querySelectorAll('.review-mark').forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
|
|
showReviewProgress(snapshot);
|
|
openNextUnreviewed(snapshot);
|
|
});
|
|
});
|
|
const progressSnapshot = progress.snapshot();
|
|
showReviewProgress(progressSnapshot);
|
|
openNextUnreviewed(progressSnapshot);
|
|
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
|
|
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
|
|
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small markdown-content">' + renderMarkdown(review.body) + '</div>' : '') + '</div>'
|
|
).join('') : '<div>No prior reviews.</div>';
|
|
qs('#review-sheet-status').textContent = offlineReview ?
|
|
'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' :
|
|
'Ready to review · by ' + (detail.author || 'unknown author');
|
|
qs('#submit-review').disabled = offlineReview;
|
|
} catch (error) {
|
|
if (selectedReview !== item) return;
|
|
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
|
|
qs('#retry-review-load').hidden = false;
|
|
qs('#retry-review-load').focus();
|
|
}
|
|
}
|
|
|
|
function closeReviewSheet(navigate = true) {
|
|
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
|
workRoute.close();
|
|
return;
|
|
}
|
|
qs('#review-sheet').classList.remove('open');
|
|
selectedReview = null;
|
|
offlineReview = false;
|
|
progress = null;
|
|
draft = null;
|
|
reviewFiles = [];
|
|
selectedReviewHead = '';
|
|
closeInlineComposer();
|
|
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
|
}
|
|
|
|
function closeUpdateSheet(restoreTrigger = true, navigate = true) {
|
|
if (navigate && createWorkRoute.parse(window.location.hash)) {
|
|
workRoute.close();
|
|
return;
|
|
}
|
|
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
|
|
qs('#update-sheet').classList.remove('open');
|
|
selectedUpdate = null;
|
|
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
|
else qs('[data-work-filter="update"]')?.focus();
|
|
}
|
|
|
|
function markMyWorkStale() {
|
|
qs('#my-work').setAttribute('data-stale', 'true');
|
|
qs('#my-work-status').textContent = lastMyWork.length ?
|
|
'Update failed · showing last known work' : 'Work inbox unavailable.';
|
|
}
|
|
|
|
function markNotificationsStale() {
|
|
qs('#my-work').setAttribute('data-stale', 'true');
|
|
qs('#my-work-status').textContent = lastNotifications.length ?
|
|
'Unread updates unavailable · showing last known updates' :
|
|
'Unread updates unavailable · assigned work is fresh';
|
|
}
|
|
|
|
function paintDeltas(deltas) {
|
|
const el = qs('#ai');
|
|
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
|
}
|
|
|
|
function paintEventStream(events) {
|
|
const el = qs('#gitea-events');
|
|
el.innerHTML = events.length ? events.slice(0, 12).map(event =>
|
|
'<div class="event"><strong>' + escapeHtml(event.actor?.login || 'Gitea') + '</strong> ' +
|
|
escapeHtml(event.type || 'activity') +
|
|
'<div class="small">' + escapeHtml(event.repo?.full_name || '') +
|
|
(event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '</div></div>'
|
|
).join('') : '<div class="muted">No recent Gitea activity.</div>';
|
|
}
|
|
|
|
function setEventStreamStatus(message) {
|
|
qs('#gitea-events-status').textContent = message;
|
|
}
|
|
|
|
function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
|
|
setOfflineWorkMode(false);
|
|
offlineStatus.hidden = true;
|
|
const contextFreshness = snapshot.freshness?.sections?.context;
|
|
const eventsFreshness = snapshot.freshness?.sections?.events;
|
|
const notificationFreshness = snapshot.freshness?.sections?.notifications;
|
|
const contextChanged = changedSections.includes('context');
|
|
const notificationsChanged = changedSections.includes('notifications');
|
|
const eventsChanged = changedSections.includes('events');
|
|
const workChanged = contextChanged || notificationsChanged;
|
|
const hasNotifications = Array.isArray(snapshot.notifications);
|
|
const notificationsFresh = hasNotifications && !notificationFreshness?.stale;
|
|
if (notificationsChanged && hasNotifications) {
|
|
if (notificationPagination.page > 1) {
|
|
const byId = new Map(lastNotifications.map(item => [item.id, item]));
|
|
snapshot.notifications.forEach(item => byId.set(item.id, item));
|
|
lastNotifications = Array.from(byId.values());
|
|
} else {
|
|
lastNotifications = snapshot.notifications;
|
|
}
|
|
if (snapshot.notification_pagination) {
|
|
const page = notificationPagination.page > 1 ? notificationPagination.page :
|
|
snapshot.notification_pagination.page;
|
|
notificationPager.reset({
|
|
page,
|
|
total: snapshot.notification_pagination.total,
|
|
has_more: page * 50 < snapshot.notification_pagination.total,
|
|
});
|
|
}
|
|
}
|
|
if (snapshot.context && workChanged) {
|
|
setOfflineWorkMode(false);
|
|
const retainedPlanningLogin = !snapshot.context.error ?
|
|
String(snapshot.context.user?.login || '').trim() : '';
|
|
planningOwnerLogin = retainedPlanningLogin;
|
|
updatePlanningAvailability();
|
|
if (planningOwnerLogin) {
|
|
todaySync.migrate(todayWork.read());
|
|
todaySync.flush();
|
|
}
|
|
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
|
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
|
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
|
if (activeFlushLogin) {
|
|
confirmedOwnerLogin = activeFlushLogin;
|
|
updateDeliveryReceiptControls();
|
|
}
|
|
snapshot.context.notifications = lastNotifications;
|
|
renderContextSnapshot(snapshot.context);
|
|
if (contextFreshness?.stale) markMyWorkStale();
|
|
else if (!notificationsFresh) markNotificationsStale();
|
|
if (!contextFreshness?.stale && notificationsFresh) {
|
|
offlineWorkStore.save({
|
|
...snapshot.context,
|
|
notifications: snapshot.notifications,
|
|
notification_pagination: snapshot.notification_pagination,
|
|
});
|
|
updateOfflineWorkControls();
|
|
warmTodayOffline();
|
|
}
|
|
flushIssueOutbox();
|
|
flushAuthoredOutbox();
|
|
flushNotificationReadOutbox();
|
|
} else if (!snapshot.context) handleContextError(new Error('Context section unavailable'));
|
|
if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
|
|
if (eventsFreshness?.revalidating) {
|
|
setEventStreamStatus('Refreshing activity · showing last activity');
|
|
} else if (eventsFreshness?.stale || eventsFreshness?.degraded) {
|
|
const retrySeconds = Number(eventsFreshness.retry_in_seconds) || 0;
|
|
setEventStreamStatus('Activity refresh failed · showing last activity' +
|
|
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
|
|
} else if (Array.isArray(snapshot.events)) {
|
|
setEventStreamStatus('Updated ' + fmt(new Date()));
|
|
} else {
|
|
setEventStreamStatus('Update failed · showing last activity');
|
|
}
|
|
// Compatibility fallback for snapshots produced before section metadata.
|
|
if (!eventsFreshness && snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
|
|
const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0;
|
|
setEventStreamStatus('Refresh failed · showing last known data' +
|
|
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
|
|
} else if (!eventsFreshness && snapshot.freshness?.revalidating) {
|
|
setEventStreamStatus('Refreshing · showing recent snapshot');
|
|
}
|
|
}
|
|
|
|
|
|
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
function escAttr(s) { return escapeHtml(s); }
|
|
function openModal(id) { qs('#' + id).classList.add('open'); }
|
|
function closeModal(id) { qs('#' + id).classList.remove('open'); }
|
|
|
|
/* Creative ambient background */
|
|
(function bg(){
|
|
const c=qs('#bg'),ctx=c.getContext('2d');
|
|
let w,h,time=0;
|
|
const resize=()=>{ w=c.width=innerWidth; h=c.height=innerHeight; };
|
|
resize(); addEventListener('resize', resize);
|
|
const draw=()=>{
|
|
time+=0.012;
|
|
ctx.clearRect(0,0,w,h);
|
|
ctx.strokeStyle='#1f3a5f'; ctx.lineWidth=1;
|
|
const step=60;
|
|
for(let x=0;x<w;x+=step){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
|
|
for(let y=0;y<h;y+=step){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
|
|
const cx=w/2+Math.sin(time*0.31)*90, cy=h/2+Math.cos(time*0.37)*70;
|
|
const grad=ctx.createRadialGradient(cx,cy,20,cx,cy,260);
|
|
grad.addColorStop(0,'rgba(96,165,250,.28)'); grad.addColorStop(1,'rgba(96,165,250,0)');
|
|
ctx.fillStyle=grad; ctx.fillRect(0,0,w,h);
|
|
requestAnimationFrame(draw);
|
|
};
|
|
draw();
|
|
})();
|
|
|
|
/* Whiteboard */
|
|
function initWhiteboard() {
|
|
const canvas = qs('#wb'), ctx = canvas.getContext('2d');
|
|
let drawing = false;
|
|
function resize() { const dpr = window.devicePixelRatio||1; const r=canvas.getBoundingClientRect(); canvas.width=r.width*dpr; canvas.height=r.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#e5e7eb'; ctx.lineWidth=2; }
|
|
resize(); addEventListener('resize', resize);
|
|
const p = (e) => ({ x: e.clientX - canvas.getBoundingClientRect().left, y: e.clientY - canvas.getBoundingClientRect().top });
|
|
canvas.addEventListener('pointerdown', (e)=>{ drawing=true; const pos=p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); });
|
|
canvas.addEventListener('pointermove', (e)=>{ if (!drawing) return; const pos=p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); });
|
|
canvas.addEventListener('pointerup', () => drawing=false);
|
|
canvas.addEventListener('pointerleave', () => drawing=false);
|
|
qs('#wb-clear').addEventListener('click', () => { const r=canvas.getBoundingClientRect(); ctx.clearRect(0,0,r.width,r.height); });
|
|
qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); });
|
|
}
|
|
|
|
/* Markdown */
|
|
qs('#md-input').addEventListener('input', renderMD);
|
|
function renderMD() {
|
|
const raw = qs('#md-input').value || '';
|
|
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
|
|
}
|
|
|
|
/* Commands */
|
|
const commands = [
|
|
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
|
|
{ name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
|
|
{ name: 'Refresh now', run: load },
|
|
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
|
|
];
|
|
let commandSearchState = { status:'idle', query:'', items:[] };
|
|
let commandItems = [];
|
|
let commandSelection = -1;
|
|
async function searchGlobalWork(query, signal) {
|
|
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', {
|
|
headers: { Accept:'application/json' },
|
|
signal,
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
|
|
return {
|
|
items: Array.isArray(payload.items) ? payload.items : [],
|
|
partial: payload.partial === true,
|
|
};
|
|
}
|
|
const commandSearch = filterCommands.createGlobalSearchController({
|
|
search: searchGlobalWork,
|
|
onState: state => {
|
|
commandSearchState = state;
|
|
renderCommands(state.query);
|
|
},
|
|
});
|
|
function safeSearchUrl(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
|
} catch (_) { return '';
|
|
}
|
|
}
|
|
let searchPreviewDetail = null;
|
|
function searchPreviewPath(item) {
|
|
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
|
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
|
'/preview?kind=' + encodeURIComponent(item.kind);
|
|
}
|
|
function renderSearchPreview(state) {
|
|
const sheet = qs('#search-preview');
|
|
const status = qs('#search-preview-status');
|
|
const claimButton = qs('#claim-search-result');
|
|
if (state.status === 'closed') {
|
|
sheet.classList.remove('open');
|
|
return;
|
|
}
|
|
sheet.classList.add('open');
|
|
claimButton.hidden = true;
|
|
claimButton.disabled = false;
|
|
if (state.status === 'loading') {
|
|
searchPreviewDetail = null;
|
|
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
|
|
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
|
|
qs('#search-preview-meta').textContent = '';
|
|
qs('#search-preview-body').textContent = '';
|
|
qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#';
|
|
status.textContent = 'Loading preview…';
|
|
return;
|
|
}
|
|
if (state.status === 'error') {
|
|
status.textContent = state.error?.message || 'Preview unavailable. Open this result in Gitea or retry.';
|
|
return;
|
|
}
|
|
const detail = state.detail;
|
|
if (!detail) return;
|
|
searchPreviewDetail = detail;
|
|
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
|
|
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
|
|
qs('#search-preview-meta').textContent =
|
|
(detail.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + (detail.state || 'unknown') +
|
|
(detail.author ? ' · by ' + detail.author : '') +
|
|
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
|
|
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
|
|
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
|
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
|
|
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
|
|
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
|
claimButton.disabled = state.status === 'claiming';
|
|
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' :
|
|
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
|
|
(detail.claimable ? 'This issue is open and unassigned.' :
|
|
(detail.assigned_to_me ? 'This issue is already in My Work.' : 'Read-only preview.')));
|
|
}
|
|
const searchPreview = createSearchPreview({
|
|
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
|
|
claim: detail => fetchReviewJson(
|
|
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
|
'/issues/' + encodeURIComponent(detail.number) + '/claim',
|
|
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
|
),
|
|
onState: renderSearchPreview,
|
|
});
|
|
const mobileSearchViewport = createMobileSearchViewport({
|
|
palette: qs('#cmd-palette'),
|
|
results: qs('#cmd-results'),
|
|
viewport: window.visualViewport,
|
|
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
|
schedule: callback => requestAnimationFrame(callback),
|
|
});
|
|
function closeSearchPreview(navigate = true) {
|
|
if (navigate && taskOverlayHistory.current() === 'search-preview') {
|
|
taskOverlayHistory.close();
|
|
return;
|
|
}
|
|
searchPreview.close();
|
|
qs('#cmd-palette').classList.add('open');
|
|
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
|
renderCommands(qs('#cmd-input').value);
|
|
qs('#cmd-input').focus();
|
|
}
|
|
async function openPreviewIssueInMyWork(detail) {
|
|
await load();
|
|
const item = lastMyWork.find(candidate =>
|
|
candidate.kind === 'issue' && candidate.key === detail.repository + '#' + detail.number
|
|
);
|
|
if (!item) return false;
|
|
taskOverlayHistory.leave();
|
|
openRoutedWork(item, qs('#find-work'));
|
|
return true;
|
|
}
|
|
function runCommandItem(item) {
|
|
if (item.command) {
|
|
taskOverlayHistory.leave();
|
|
item.command.run();
|
|
qs('#cmd-input').value = '';
|
|
} else {
|
|
mobileSearchViewport.rememberScroll();
|
|
searchPreview.open(item.result).catch(() => {});
|
|
taskOverlayHistory.open('search-preview');
|
|
}
|
|
qs('#cmd-palette').classList.remove('open');
|
|
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
|
}
|
|
function renderCommands(filter) {
|
|
const el = qs('#cmd-results');
|
|
const local = filterCommands(commands, filter).map(command => ({ command }));
|
|
const remote = commandSearchState.query === String(filter || '').trim()
|
|
? commandSearchState.items.map(result => ({ result })) : [];
|
|
commandItems = local.concat(remote);
|
|
if (commandSelection >= commandItems.length) commandSelection = -1;
|
|
let html = local.length ? '<div class="cmd-group">Commands</div>' : '';
|
|
html += local.map((item, idx) => '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(item.command.name) + '</span><span class="cmd-meta">Command</span></div>').join('');
|
|
if (remote.length) html += '<div class="cmd-group">Issues and pull requests</div>';
|
|
html += remote.map((item, remoteIdx) => {
|
|
const idx = local.length + remoteIdx;
|
|
const result = item.result;
|
|
return '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '</span></div>';
|
|
}).join('');
|
|
if (commandSearchState.status === 'loading') html += '<div class="cmd-status">Searching accessible work…</div>';
|
|
else if (commandSearchState.status === 'error') html += '<div class="cmd-status">Search unavailable. Keep typing or retry.</div>';
|
|
else if (commandSearchState.partial) html += '<div class="cmd-status">Some results are temporarily unavailable.</div>';
|
|
else if (String(filter || '').trim().length >= 2 && !remote.length) html += '<div class="cmd-status">No matching issues or pull requests.</div>';
|
|
el.innerHTML = html;
|
|
el.querySelectorAll('.cmd-item').forEach((item) => {
|
|
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
|
|
});
|
|
}
|
|
function openCommandPalette(navigate = true) {
|
|
if (navigate) {
|
|
taskOverlayHistory.open('search');
|
|
return;
|
|
}
|
|
qs('#cmd-palette').classList.add('open');
|
|
mobileSearchViewport.open();
|
|
mobileSearchViewport.restoreScroll();
|
|
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
|
qs('#cmd-input').focus();
|
|
commandSelection = -1;
|
|
renderCommands(qs('#cmd-input').value);
|
|
}
|
|
const taskOverlayHistory = createTaskOverlayHistory({
|
|
history: window.history,
|
|
eventTarget: window,
|
|
onChange(kind, previous) {
|
|
if (previous === 'new' && kind !== 'new') {
|
|
if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft();
|
|
suppressCreateDraftOnHistoryClose = false;
|
|
closeCreateIssueSheet(false);
|
|
}
|
|
if (previous === 'find' && kind !== 'find') closeFindWorkSheet(false);
|
|
if (previous === 'search-preview' && kind !== 'search-preview') {
|
|
if (kind === 'search') closeSearchPreview(false);
|
|
else {
|
|
searchPreview.close();
|
|
mobileSearchViewport.close();
|
|
}
|
|
}
|
|
if (previous === 'search' && kind !== 'search' && kind !== 'search-preview') {
|
|
qs('#cmd-palette').classList.remove('open');
|
|
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
|
mobileSearchViewport.close();
|
|
qs('#open-palette').focus();
|
|
}
|
|
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
|
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
|
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
|
|
},
|
|
});
|
|
taskOverlayHistory.start();
|
|
qs('#open-palette').addEventListener('click', openCommandPalette);
|
|
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
|
qs('#cmd-input').addEventListener('input', (e) => {
|
|
commandSelection = -1;
|
|
renderCommands(e.target.value);
|
|
commandSearch.setQuery(e.target.value);
|
|
});
|
|
qs('#cmd-input').addEventListener('keydown', event => {
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length);
|
|
renderCommands(event.currentTarget.value);
|
|
const selected = qs('#cmd-results .selected');
|
|
if (selected) selected.scrollIntoView({ block:'nearest' });
|
|
} else if (event.key === 'Enter' && commandSelection >= 0) {
|
|
event.preventDefault();
|
|
runCommandItem(commandItems[commandSelection]);
|
|
}
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) {
|
|
e.preventDefault();
|
|
taskOverlayHistory.close();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && qs('#search-preview').classList.contains('open')) {
|
|
e.preventDefault();
|
|
closeSearchPreview();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && findingWork) {
|
|
e.preventDefault();
|
|
closeFindWorkSheet();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && creatingIssue) {
|
|
e.preventDefault();
|
|
saveIssueCaptureDraft();
|
|
closeCreateIssueSheet();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && selectedIssue) {
|
|
e.preventDefault();
|
|
closeIssueSheet();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && selectedPull) {
|
|
e.preventDefault();
|
|
closePullSheet();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && selectedReview) {
|
|
e.preventDefault();
|
|
closeReviewSheet();
|
|
return;
|
|
}
|
|
if (e.key === 'Escape' && selectedUpdate) {
|
|
e.preventDefault();
|
|
closeUpdateSheet();
|
|
return;
|
|
}
|
|
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
|
|
e.preventDefault();
|
|
if (qs('#cmd-palette').classList.contains('open')) {
|
|
taskOverlayHistory.close();
|
|
} else openCommandPalette();
|
|
}
|
|
});
|
|
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
|
|
document.querySelectorAll('.share-work-route').forEach(button => {
|
|
button.addEventListener('click', async () => {
|
|
const status = qs('#work-route-share-status');
|
|
try {
|
|
const result = await createWorkRoute.share(window.location.href, navigator, navigator.clipboard);
|
|
status.textContent = result === 'shared' ? 'Work link shared.' : 'Work link copied.';
|
|
} catch (error) {
|
|
status.textContent = error?.name === 'AbortError' ? 'Share canceled.' : 'Could not share this work link.';
|
|
}
|
|
});
|
|
});
|
|
qs('#claim-search-result').addEventListener('click', async () => {
|
|
if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return;
|
|
try {
|
|
const claimed = searchPreviewDetail;
|
|
if (claimed.claimable) await searchPreview.claim(claimed);
|
|
const opened = await openPreviewIssueInMyWork(claimed);
|
|
if (!opened) {
|
|
qs('#search-preview-status').textContent =
|
|
'Assignment confirmed. Refresh My Work to open the issue.';
|
|
}
|
|
} catch (error) {
|
|
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
|
|
}
|
|
});
|
|
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
|
qs('#find-work').addEventListener('click', openFindWorkSheet);
|
|
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
|
|
qs('#load-more-available').addEventListener('click', async event => {
|
|
event.currentTarget.disabled = true;
|
|
qs('#find-work-status').textContent = 'Loading more available issues…';
|
|
try {
|
|
await findWorkController.loadMore();
|
|
qs('#find-work-status').textContent = findWorkController.items().length + ' of ' +
|
|
availablePagination.total + ' available issues loaded.';
|
|
} catch (error) {
|
|
qs('#find-work-status').textContent = error.message + ' Retry loading more.';
|
|
} finally {
|
|
event.currentTarget.disabled = false;
|
|
}
|
|
});
|
|
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
|
qs('#save-unfiled-issue').addEventListener('click', () => {
|
|
const captureDraft = {
|
|
title: qs('#create-issue-title').value.trim(),
|
|
body: qs('#create-issue-body').value.trim(),
|
|
};
|
|
try {
|
|
unfiledCaptures.save(captureDraft);
|
|
issueCapture.clearDraft();
|
|
qs('#create-issue-title').value = '';
|
|
qs('#create-issue-body').value = '';
|
|
closeCreateIssueSheet(true, false);
|
|
refreshMyWorkView();
|
|
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
|
|
} catch (error) {
|
|
qs('#create-issue-status').textContent = error.message;
|
|
qs('#create-issue-title').focus();
|
|
}
|
|
});
|
|
qs('#use-shared-content').addEventListener('click', () => {
|
|
issueCapture.acceptSharedContent();
|
|
qs('#shared-content-conflict').hidden = true;
|
|
sharedLaunchState = null;
|
|
clearSharedLaunchUrl();
|
|
openCreateIssueSheet();
|
|
qs('#create-issue-status').textContent = 'Shared content added. Choose a repository and finish planning.';
|
|
});
|
|
qs('#resume-issue-draft').addEventListener('click', () => {
|
|
issueCapture.discardSharedContent();
|
|
qs('#shared-content-conflict').hidden = true;
|
|
sharedLaunchState = null;
|
|
clearSharedLaunchUrl();
|
|
qs('#create-issue-status').textContent = 'Existing draft restored.';
|
|
qs('#create-issue-title').focus();
|
|
});
|
|
qs('#cancel-new-issue').addEventListener('click', () => {
|
|
const discardEditedDraft = Boolean(editingOutboxId);
|
|
if (editingOutboxId) {
|
|
issueCapture.clearDraft();
|
|
editingOutboxId = null;
|
|
} else saveIssueCaptureDraft();
|
|
closeCreateIssueSheet(true, !discardEditedDraft);
|
|
});
|
|
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
|
|
qs(selector).addEventListener('input', saveIssueCaptureDraft)
|
|
);
|
|
qs('#create-issue-repository').addEventListener('change', event => {
|
|
loadIssueLabels(event.target.value);
|
|
loadIssueMilestones(event.target.value);
|
|
saveIssueCaptureDraft();
|
|
});
|
|
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
|
|
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
|
|
qs('#create-issue-form').addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const captureDraft = {
|
|
repository: qs('#create-issue-repository').value,
|
|
title: qs('#create-issue-title').value.trim(),
|
|
body: qs('#create-issue-body').value.trim(),
|
|
labelIds: selectedIssueLabelIds(),
|
|
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
|
dueDate: qs('#create-issue-due-date').value,
|
|
};
|
|
if (!captureDraft.repository || !captureDraft.title) {
|
|
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
|
qs('#create-issue-title').focus();
|
|
return;
|
|
}
|
|
const button = qs('#submit-new-issue');
|
|
button.disabled = true;
|
|
qs('#create-issue-status').textContent = 'Saving for background delivery…';
|
|
try {
|
|
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, captureDraft) :
|
|
await issueOutbox.enqueueDurably(captureDraft);
|
|
const queued = admission.item;
|
|
if (!admission.background) {
|
|
editingOutboxId = queued.id;
|
|
refreshMyWorkView();
|
|
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
|
|
button.disabled = false;
|
|
return;
|
|
}
|
|
editingOutboxId = null;
|
|
issueCapture.clearDraft();
|
|
suppressCreateDraftOnHistoryClose = true;
|
|
taskOverlayHistory.leave();
|
|
refreshMyWorkView();
|
|
qs('#my-work-action-status').textContent = 'Queued for sync.';
|
|
if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true);
|
|
} catch (error) {
|
|
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
|
button.disabled = false;
|
|
qs('#create-issue-title').focus();
|
|
}
|
|
});
|
|
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
|
|
qs('#retry-issue-load').addEventListener('click', () => {
|
|
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
|
});
|
|
qs('#issue-planning').addEventListener('toggle', event => {
|
|
if (event.currentTarget.open) loadIssuePlanning();
|
|
});
|
|
qs('#retry-issue-planning').addEventListener('click', loadIssuePlanning);
|
|
qs('#edit-issue-content').addEventListener('click', () => {
|
|
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
|
|
const draft = issueController.loadEditDraft(selectedIssue) || {
|
|
title: selectedIssueDetail.title || '',
|
|
body: selectedIssueDetail.body || '',
|
|
expectedUpdatedAt: selectedIssueDetail.updated_at,
|
|
};
|
|
qs('#issue-edit-title').value = draft.title;
|
|
qs('#issue-edit-body').value = draft.body;
|
|
qs('#issue-edit-form').hidden = false;
|
|
qs('#issue-edit-status').textContent = 'Edit the issue, then save.';
|
|
qs('#issue-edit-title').focus();
|
|
});
|
|
['#issue-edit-title', '#issue-edit-body'].forEach(selector =>
|
|
qs(selector).addEventListener('input', () => {
|
|
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
|
|
issueController.saveEditDraft(selectedIssue, {
|
|
title: qs('#issue-edit-title').value,
|
|
body: qs('#issue-edit-body').value,
|
|
expectedUpdatedAt: issueController.loadEditDraft(selectedIssue)?.expectedUpdatedAt || selectedIssueDetail.updated_at,
|
|
});
|
|
})
|
|
);
|
|
qs('#cancel-issue-content').addEventListener('click', () => {
|
|
qs('#issue-edit-form').hidden = true;
|
|
qs('#edit-issue-content').focus();
|
|
});
|
|
qs('#issue-edit-form').addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
if (!selectedIssue || !selectedIssueDetail?.updated_at || !lastContextSnapshot) return;
|
|
const title = qs('#issue-edit-title').value.trim();
|
|
const body = qs('#issue-edit-body').value.trim();
|
|
if (!title) {
|
|
qs('#issue-edit-status').textContent = 'Add a title before saving.';
|
|
qs('#issue-edit-title').focus();
|
|
return;
|
|
}
|
|
const editing = selectedIssue;
|
|
const savedDraft = issueController.loadEditDraft(editing);
|
|
const draft = {
|
|
title,
|
|
body,
|
|
expectedUpdatedAt: savedDraft?.expectedUpdatedAt || selectedIssueDetail.updated_at,
|
|
};
|
|
const button = qs('#save-issue-content');
|
|
button.disabled = true;
|
|
qs('#issue-edit-status').textContent = 'Saving issue…';
|
|
try {
|
|
const confirmed = await issueController.updateContent(editing, draft);
|
|
lastContextSnapshot = buildMyWork.replaceIssueContent(
|
|
lastContextSnapshot, editing.repository, editing.number, confirmed
|
|
);
|
|
selectedIssue = { ...editing, ...confirmed, key: editing.key };
|
|
selectedIssueDetail = { ...selectedIssueDetail, ...confirmed };
|
|
qs('#issue-sheet-title').textContent = confirmed.title;
|
|
qs('#issue-sheet-body').innerHTML = renderMarkdown(confirmed.body || 'No description provided.');
|
|
paintMyWork(lastContextSnapshot);
|
|
qs('#issue-edit-form').hidden = true;
|
|
qs('#issue-sheet-status').textContent = 'Issue saved.';
|
|
qs('#edit-issue-content').focus();
|
|
} catch (error) {
|
|
qs('#issue-edit-status').textContent = error.message + ' Your draft is safe; reload latest or open in Gitea.';
|
|
qs('#retry-issue-load').hidden = false;
|
|
qs('#issue-edit-title').focus();
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
qs('#issue-comment').addEventListener('input', event => {
|
|
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
|
|
});
|
|
qs('#load-older-issue-comments').addEventListener('click', async () => {
|
|
if (!issueConversation) return;
|
|
const button = qs('#load-older-issue-comments');
|
|
const panel = qs('#issue-sheet .issue-sheet-panel');
|
|
const previousHeight = panel.scrollHeight;
|
|
button.disabled = true;
|
|
qs('#issue-conversation-status').textContent = 'Loading older messages…';
|
|
try {
|
|
renderIssueConversation(await issueConversation.loadOlder());
|
|
panel.scrollTop += panel.scrollHeight - previousHeight;
|
|
} catch (error) {
|
|
qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
qs('#save-issue-labels').addEventListener('click', async () => {
|
|
if (!selectedIssue || !lastContextSnapshot) return;
|
|
const editing = selectedIssue;
|
|
const button = qs('#save-issue-labels');
|
|
button.disabled = true;
|
|
qs('#issue-label-status').textContent = 'Saving labels…';
|
|
try {
|
|
const confirmed = await issueController.updateLabels(selectedIssue, selectedEditIssueLabelIds());
|
|
lastContextSnapshot = buildMyWork.replaceIssueLabels(
|
|
lastContextSnapshot, editing.repository, editing.number, confirmed.labels
|
|
);
|
|
selectedIssue = { ...editing, labels: confirmed.labels };
|
|
qs('#issue-labels').innerHTML = confirmed.labels.map(label =>
|
|
'<span class="pill">' + escapeHtml(label) + '</span>'
|
|
).join(' ');
|
|
paintMyWork(lastContextSnapshot);
|
|
qs('#issue-label-status').textContent = 'Labels saved. My Work reprioritized.';
|
|
} catch (error) {
|
|
qs('#issue-label-status').textContent = error.message + ' Your selection is safe; retry.';
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
|
|
async function saveSelectedIssueDueDate(dueDate) {
|
|
if (!selectedIssue || !lastContextSnapshot) return;
|
|
const editing = selectedIssue;
|
|
const saveButton = qs('#save-issue-due-date');
|
|
const clearButton = qs('#clear-issue-due-date');
|
|
saveButton.disabled = true;
|
|
clearButton.disabled = true;
|
|
qs('#issue-due-status').textContent = dueDate ? 'Saving due date…' : 'Clearing due date…';
|
|
try {
|
|
const confirmed = await issueController.updateDueDate(editing, dueDate);
|
|
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
|
|
lastContextSnapshot, editing.repository, editing.number, confirmed.due_date
|
|
);
|
|
selectedIssue = { ...editing, due_date: confirmed.due_date };
|
|
selectedIssueDetail = { ...selectedIssueDetail, due_date: confirmed.due_date };
|
|
qs('#issue-due-date').value = String(confirmed.due_date || '').slice(0, 10);
|
|
paintMyWork(lastContextSnapshot);
|
|
qs('#issue-due-status').textContent = confirmed.due_date ?
|
|
'Due date saved. My Work reprioritized.' : 'Due date cleared.';
|
|
} catch (error) {
|
|
qs('#issue-due-status').textContent = error.message + ' Your selection is safe; retry.';
|
|
qs('#issue-due-date').focus();
|
|
} finally {
|
|
saveButton.disabled = false;
|
|
clearButton.disabled = !selectedIssueDetail?.due_date;
|
|
}
|
|
}
|
|
|
|
qs('#save-issue-due-date').addEventListener('click', () => {
|
|
const value = qs('#issue-due-date').value;
|
|
if (!value) {
|
|
qs('#issue-due-status').textContent = 'Choose a date or use Clear due date.';
|
|
qs('#issue-due-date').focus();
|
|
return;
|
|
}
|
|
saveSelectedIssueDueDate(value + 'T23:59:59Z');
|
|
});
|
|
qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null));
|
|
qs('#save-issue-milestone').addEventListener('click', async () => {
|
|
if (!selectedIssue || !lastContextSnapshot) return;
|
|
const editing = selectedIssue;
|
|
const button = qs('#save-issue-milestone');
|
|
const raw = qs('#issue-milestone').value;
|
|
const milestoneId = raw ? Number(raw) : null;
|
|
button.disabled = true;
|
|
qs('#issue-milestone').disabled = true;
|
|
qs('#issue-milestone-status').textContent = milestoneId ? 'Saving milestone…' : 'Clearing milestone…';
|
|
try {
|
|
const confirmed = await issueController.updateMilestone(editing, milestoneId);
|
|
lastContextSnapshot = buildMyWork.replaceIssueMilestone(
|
|
lastContextSnapshot, editing.repository, editing.number, confirmed.milestone
|
|
);
|
|
selectedIssue = { ...editing, milestone: confirmed.milestone };
|
|
selectedIssueDetail = { ...selectedIssueDetail, milestone: confirmed.milestone };
|
|
paintMyWork(lastContextSnapshot);
|
|
qs('#issue-milestone-status').textContent = confirmed.milestone ?
|
|
'Planned for ' + confirmed.milestone.title + '. Release lane updated.' : 'Milestone cleared.';
|
|
} catch (error) {
|
|
qs('#issue-milestone-status').textContent = error.message + ' Your selection is safe; retry.';
|
|
qs('#issue-milestone').focus();
|
|
} finally {
|
|
button.disabled = false;
|
|
qs('#issue-milestone').disabled = false;
|
|
}
|
|
});
|
|
qs('#send-issue-comment').addEventListener('click', async () => {
|
|
if (!selectedIssue) return;
|
|
const body = qs('#issue-comment').value.trim();
|
|
if (!body) {
|
|
qs('#issue-comment-status').textContent = 'Write a comment before posting.';
|
|
qs('#issue-comment').focus();
|
|
return;
|
|
}
|
|
const button = qs('#send-issue-comment');
|
|
button.disabled = true;
|
|
qs('#issue-comment-status').textContent = 'Posting comment…';
|
|
try {
|
|
const comment = await issueController.comment(selectedIssue, body);
|
|
if (issueConversation) renderIssueConversation(issueConversation.append(comment));
|
|
qs('#issue-comment').value = '';
|
|
qs('#issue-comment-status').textContent = 'Comment posted.';
|
|
} catch (error) {
|
|
if (canQueueMessage(error)) {
|
|
const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
|
|
qs('#issue-comment-status').textContent = 'Saving for background delivery…';
|
|
const admission = await authoredOutbox.enqueueDurably({ kind:'issue-comment', repository:selectedIssue.repository,
|
|
number:selectedIssue.number, body, operationId });
|
|
refreshMyWorkView();
|
|
if (admission.background) {
|
|
qs('#issue-comment').value = '';
|
|
qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
|
|
} else {
|
|
qs('#issue-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
|
|
qs('#issue-comment').focus();
|
|
}
|
|
} else {
|
|
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
|
qs('#issue-comment').focus();
|
|
}
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
qs('#release-issue').addEventListener('click', async () => {
|
|
if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return;
|
|
const releasing = selectedIssue;
|
|
const button = qs('#release-issue');
|
|
button.disabled = true;
|
|
qs('#issue-sheet-status').textContent = 'Releasing assignment…';
|
|
try {
|
|
const confirmed = await issueController.release(selectedIssue, lastContextSnapshot?.user?.login);
|
|
lastContextSnapshot = buildMyWork.removeIssue(
|
|
lastContextSnapshot, releasing.repository, releasing.number
|
|
);
|
|
closeIssueSheet();
|
|
paintMyWork(lastContextSnapshot);
|
|
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
|
|
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
|
|
} catch (error) {
|
|
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
|
button.disabled = false;
|
|
button.focus();
|
|
}
|
|
});
|
|
qs('#load-issue-handoff').addEventListener('click', async () => {
|
|
if (!selectedIssue) return;
|
|
const button = qs('#load-issue-handoff');
|
|
const select = qs('#issue-handoff-recipient');
|
|
button.disabled = true;
|
|
qs('#issue-handoff-status').textContent = 'Loading eligible teammates…';
|
|
try {
|
|
const candidates = await issueController.loadHandoffCandidates(selectedIssue);
|
|
select.textContent = '';
|
|
const placeholder = document.createElement('option');
|
|
placeholder.value = '';
|
|
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
|
|
select.appendChild(placeholder);
|
|
candidates.forEach(candidate => {
|
|
const option = document.createElement('option');
|
|
option.value = candidate.login;
|
|
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
|
|
select.appendChild(option);
|
|
});
|
|
select.disabled = !candidates.length;
|
|
qs('#confirm-issue-handoff').disabled = true;
|
|
qs('#issue-handoff-status').textContent = candidates.length ?
|
|
'Choose who should own this issue next.' : 'No other eligible assignees were found.';
|
|
if (candidates.length) select.focus();
|
|
} catch (error) {
|
|
qs('#issue-handoff-status').textContent = error.message + ' Retry loading teammates.';
|
|
button.disabled = false;
|
|
button.focus();
|
|
}
|
|
});
|
|
qs('#issue-handoff-recipient').addEventListener('change', event => {
|
|
qs('#confirm-issue-handoff').disabled = !event.target.value;
|
|
});
|
|
qs('#confirm-issue-handoff').addEventListener('click', async () => {
|
|
const recipient = qs('#issue-handoff-recipient').value;
|
|
if (!selectedIssue || !recipient || !window.confirm('Hand off ' + selectedIssue.key + ' to @' + recipient + '?')) return;
|
|
const handingOff = selectedIssue;
|
|
const button = qs('#confirm-issue-handoff');
|
|
button.disabled = true;
|
|
qs('#issue-handoff-status').textContent = 'Confirming handoff…';
|
|
try {
|
|
await issueController.handoff(selectedIssue, recipient, lastContextSnapshot?.user?.login);
|
|
lastContextSnapshot = buildMyWork.removeIssue(
|
|
lastContextSnapshot, handingOff.repository, handingOff.number
|
|
);
|
|
closeIssueSheet();
|
|
paintMyWork(lastContextSnapshot);
|
|
qs('#my-work-action-status').textContent = handingOff.key + ' handed off to @' + recipient + '.';
|
|
} catch (error) {
|
|
qs('#issue-handoff-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
|
button.disabled = false;
|
|
button.focus();
|
|
}
|
|
});
|
|
qs('#close-issue').addEventListener('click', async () => {
|
|
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
|
|
const closing = selectedIssue;
|
|
const button = qs('#close-issue');
|
|
button.disabled = true;
|
|
qs('#issue-sheet-status').textContent = 'Closing issue…';
|
|
try {
|
|
await issueController.close(selectedIssue);
|
|
closeIssueSheet();
|
|
lastMyWork = lastMyWork.filter(item =>
|
|
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
|
|
);
|
|
refreshMyWorkView();
|
|
qs('#my-work-action-status').textContent = closing.key + ' closed.';
|
|
} catch (error) {
|
|
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
|
button.disabled = false;
|
|
button.focus();
|
|
}
|
|
});
|
|
qs('#close-pull-sheet').addEventListener('click', closePullSheet);
|
|
qs('#retry-pull-load').addEventListener('click', () => {
|
|
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
|
|
});
|
|
qs('#pull-review').addEventListener('toggle', event => {
|
|
if (event.currentTarget.open) loadPullReview();
|
|
});
|
|
qs('#pull-review-retry').addEventListener('click', loadPullReview);
|
|
qs('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile);
|
|
qs('#load-older-pull-comments').addEventListener('click', async () => {
|
|
if (!pullConversation) return;
|
|
const button = qs('#load-older-pull-comments');
|
|
const panel = qs('#pull-sheet .pull-sheet-panel');
|
|
const previousHeight = panel.scrollHeight;
|
|
button.disabled = true;
|
|
qs('#pull-conversation-status').textContent = 'Loading older messages…';
|
|
try {
|
|
renderPullConversation(await pullConversation.loadOlder());
|
|
panel.scrollTop += panel.scrollHeight - previousHeight;
|
|
} catch (error) {
|
|
qs('#pull-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
qs('#pull-comment').addEventListener('input', event => {
|
|
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
|
|
});
|
|
qs('#send-pull-comment').addEventListener('click', async () => {
|
|
if (!selectedPull) return;
|
|
const body = qs('#pull-comment').value.trim();
|
|
if (!body) {
|
|
qs('#pull-comment-status').textContent = 'Write a comment before posting.';
|
|
qs('#pull-comment').focus();
|
|
return;
|
|
}
|
|
const button = qs('#send-pull-comment');
|
|
button.disabled = true;
|
|
qs('#pull-comment-status').textContent = 'Posting comment…';
|
|
try {
|
|
const comment = await pullController.comment(selectedPull, body);
|
|
if (pullConversation) renderPullConversation(pullConversation.append(comment));
|
|
qs('#pull-comment').value = '';
|
|
qs('#pull-comment-status').textContent = 'Comment posted.';
|
|
} catch (error) {
|
|
if (canQueueMessage(error)) {
|
|
const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation');
|
|
qs('#pull-comment-status').textContent = 'Saving for background delivery…';
|
|
const admission = await authoredOutbox.enqueueDurably({ kind:'pull-comment', repository:selectedPull.repository,
|
|
number:selectedPull.number, body, operationId });
|
|
refreshMyWorkView();
|
|
if (admission.background) {
|
|
qs('#pull-comment').value = '';
|
|
qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.';
|
|
} else {
|
|
qs('#pull-comment-status').textContent = 'Saved for next launch; background delivery unavailable.';
|
|
qs('#pull-comment').focus();
|
|
}
|
|
} else {
|
|
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
|
qs('#pull-comment').focus();
|
|
}
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
qs('#merge-pull').addEventListener('click', async () => {
|
|
if (!selectedPull || !selectedPullDetail?.head_sha || !window.confirm('Merge ' + selectedPull.key + ' at current head?')) return;
|
|
const merging = selectedPull;
|
|
const button = qs('#merge-pull');
|
|
button.disabled = true;
|
|
qs('#pull-sheet-status').textContent = 'Merging pull request…';
|
|
try {
|
|
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
|
|
closePullSheet();
|
|
lastMyWork = lastMyWork.filter(item =>
|
|
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
|
|
);
|
|
refreshMyWorkView();
|
|
qs('#my-work-action-status').textContent = merging.key + ' merged.';
|
|
} catch (error) {
|
|
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and retry.';
|
|
button.disabled = false;
|
|
button.focus();
|
|
}
|
|
});
|
|
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
|
|
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
|
|
qs('#retry-update-load').addEventListener('click', () => {
|
|
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
|
|
});
|
|
qs('#load-older-update-comments').addEventListener('click', async () => {
|
|
const button = qs('#load-older-update-comments');
|
|
const panel = qs('#update-sheet .update-sheet-panel');
|
|
const previousHeight = panel.scrollHeight;
|
|
const previousTop = panel.scrollTop;
|
|
button.disabled = true;
|
|
await notificationReader.loadOlder();
|
|
panel.scrollTop = previousTop + (panel.scrollHeight - previousHeight);
|
|
button.disabled = false;
|
|
});
|
|
qs('#update-reply').addEventListener('input', event => {
|
|
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
|
|
});
|
|
qs('#send-update-reply').addEventListener('click', async () => {
|
|
if (!selectedUpdate) return;
|
|
const body = qs('#update-reply').value.trim();
|
|
if (!body) {
|
|
qs('#update-reply-status').textContent = 'Write a reply before sending.';
|
|
qs('#update-reply').focus();
|
|
return;
|
|
}
|
|
qs('#send-update-reply').disabled = true;
|
|
const result = await notificationReplier.submit(selectedUpdate, body);
|
|
qs('#send-update-reply').disabled = false;
|
|
if (result?.queued) {
|
|
qs('#update-reply').value = '';
|
|
refreshMyWorkView();
|
|
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
|
|
} else if (result) {
|
|
notificationReader.appendReply(result);
|
|
qs('#update-reply').value = '';
|
|
qs('#mark-update-read-next').focus();
|
|
} else {
|
|
qs('#update-reply').focus();
|
|
}
|
|
});
|
|
qs('#mark-update-read-next').addEventListener('click', async () => {
|
|
qs('#mark-update-read-next').disabled = true;
|
|
try {
|
|
await notificationReader.markReadAndNext(lastMyWork);
|
|
} finally {
|
|
qs('#mark-update-read-next').disabled = false;
|
|
}
|
|
});
|
|
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
|
qs('#retry-review-load').addEventListener('click', () => {
|
|
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
|
|
});
|
|
qs('#next-unreviewed-review').addEventListener('click', () => {
|
|
if (progress) openNextUnreviewed(progress.snapshot());
|
|
});
|
|
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
|
|
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
|
|
qs('#save-inline-comment').addEventListener('click', () => {
|
|
if (!draft || !activeInlineTarget) return;
|
|
const body = qs('#review-inline-body').value.trim();
|
|
if (!body) {
|
|
qs('#review-inline-body').focus();
|
|
return;
|
|
}
|
|
draft.setInlineComment(inlineAnchor(activeInlineTarget), body);
|
|
activeInlineTarget.classList.add('has-draft');
|
|
const target = activeInlineTarget;
|
|
closeInlineComposer();
|
|
target.focus();
|
|
});
|
|
qs('#delete-inline-comment').addEventListener('click', () => {
|
|
if (!draft || !activeInlineTarget) return;
|
|
draft.removeInlineComment(inlineAnchor(activeInlineTarget));
|
|
activeInlineTarget.classList.remove('has-draft');
|
|
const target = activeInlineTarget;
|
|
closeInlineComposer();
|
|
target.focus();
|
|
});
|
|
qs('#cancel-inline-comment').addEventListener('click', () => {
|
|
const target = activeInlineTarget;
|
|
closeInlineComposer();
|
|
target?.focus();
|
|
});
|
|
qs('#submit-review').addEventListener('click', async () => {
|
|
if (!draft || !selectedReview || !selectedReviewHead) return;
|
|
const snapshot = draft.snapshot();
|
|
const labels = { approve: 'Approve', request_changes: 'Request changes' };
|
|
if (labels[snapshot.decision] && !window.confirm(
|
|
labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?'
|
|
)) return;
|
|
const button = qs('#submit-review');
|
|
button.disabled = true;
|
|
qs('#review-submit-status').textContent = 'Submitting review…';
|
|
try {
|
|
const item = selectedReview;
|
|
const progressSnapshot = progress?.snapshot() || { reviewed: [] };
|
|
const result = await reviewController.submit(selectedReview, {
|
|
decision: snapshot.decision,
|
|
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
|
expected_head_sha: selectedReviewHead,
|
|
comments: snapshot.comments,
|
|
});
|
|
const canContinueToMerge = createReviewController.prepareMergeContinuation({
|
|
storage: localStorage,
|
|
item,
|
|
headSha: selectedReviewHead,
|
|
reviewed: progressSnapshot.reviewed,
|
|
decision: snapshot.decision,
|
|
});
|
|
draft.clear();
|
|
progress?.clear();
|
|
qs('#review-decision').value = 'comment';
|
|
qs('#review-summary').value = '';
|
|
document.querySelectorAll('.review-note').forEach(note => { note.value = ''; });
|
|
if (progress) showReviewProgress(progress.snapshot());
|
|
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
|
|
if (canContinueToMerge) {
|
|
qs('#continue-review-to-merge').hidden = false;
|
|
qs('#continue-review-to-merge').focus();
|
|
} else {
|
|
await load();
|
|
if (workSession.active()) workSession.complete();
|
|
else button.focus();
|
|
}
|
|
} catch (error) {
|
|
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
|
|
button.disabled = false;
|
|
button.focus();
|
|
}
|
|
});
|
|
qs('#continue-review-to-merge').addEventListener('click', () => {
|
|
const item = selectedReview;
|
|
if (!item) return;
|
|
workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true });
|
|
});
|
|
qs('#copy-review-feedback').addEventListener('click', async () => {
|
|
if (!draft || !selectedReview || reviewHandoffPending) return;
|
|
reviewHandoffPending = true;
|
|
qs('#copy-review-feedback').disabled = true;
|
|
const fallback = qs('#review-copy-fallback');
|
|
const directLink = qs('#review-handoff-link');
|
|
fallback.hidden = true;
|
|
directLink.hidden = true;
|
|
let handoffWindow = null;
|
|
try {
|
|
const result = await createReviewController.copyAndContinue({
|
|
text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles),
|
|
url: selectedReview.url,
|
|
copy: text => navigator.clipboard.writeText(text),
|
|
open: () => {
|
|
handoffWindow = window.open('about:blank', '_blank');
|
|
if (handoffWindow) handoffWindow.opener = null;
|
|
return handoffWindow;
|
|
},
|
|
fallback: text => {
|
|
fallback.value = text;
|
|
fallback.hidden = false;
|
|
fallback.focus();
|
|
fallback.select();
|
|
},
|
|
});
|
|
if (result.opened) {
|
|
qs('#review-handoff-status').textContent = 'Feedback copied. Gitea opened in a new tab.';
|
|
} else {
|
|
directLink.hidden = false;
|
|
qs('#review-handoff-status').textContent = result.copied ?
|
|
'Feedback copied. Your browser blocked the new tab; continue with the link below.' :
|
|
'Clipboard unavailable. Copy the selected feedback, then continue to Gitea.';
|
|
}
|
|
} finally {
|
|
reviewHandoffPending = false;
|
|
qs('#copy-review-feedback').disabled = false;
|
|
}
|
|
});
|
|
|
|
const contextPoller = createContextPoller({
|
|
fetchContext: fetchLiveSnapshot,
|
|
onSnapshot: renderLiveSnapshot,
|
|
onError: error => {
|
|
handleContextError(error);
|
|
setEventStreamStatus('Update failed · showing last activity');
|
|
},
|
|
isHidden: () => document.hidden,
|
|
intervalMs: 8000,
|
|
});
|
|
function load() { return contextPoller.refresh({ force: true }); }
|
|
|
|
const offlineStatus = qs('#offline-status');
|
|
const keepWorkOffline = qs('#keep-work-offline');
|
|
const offlineWorkStatus = qs('#offline-work-status');
|
|
const deliveryReceipts = qs('#delivery-receipts');
|
|
const deliveryReceiptStatus = qs('#delivery-receipt-status');
|
|
function updateOfflineWorkControls(message) {
|
|
keepWorkOffline.checked = offlineWorkStore.enabled();
|
|
const saved = offlineWorkStore.load();
|
|
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
|
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
|
}
|
|
async function updateDeliveryReceiptControls(message) {
|
|
const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator);
|
|
deliveryReceipts.disabled = !supported || !confirmedOwnerLogin;
|
|
if (!supported) {
|
|
deliveryReceipts.checked = false;
|
|
deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.';
|
|
return;
|
|
}
|
|
if (!confirmedOwnerLogin) {
|
|
deliveryReceipts.checked = false;
|
|
deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.';
|
|
return;
|
|
}
|
|
deliveryReceipts.checked = Notification.permission === 'granted' &&
|
|
await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin);
|
|
deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked
|
|
? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.'
|
|
: Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.');
|
|
}
|
|
function setOfflineWorkMode(value) {
|
|
offlineWorkMode = value;
|
|
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
|
|
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
|
|
if (value) {
|
|
document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')
|
|
.forEach(button => { button.disabled = true; });
|
|
}
|
|
}
|
|
function hydrateOfflineWork(mode = 'offline') {
|
|
const saved = offlineWorkStore.load();
|
|
if (!saved) return false;
|
|
const outage = mode === 'outage';
|
|
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
|
planningOwnerLogin = confirmedOwnerLogin;
|
|
updatePlanningAvailability();
|
|
saved.notifications = notificationReadOutbox.suppress(saved.notifications || []);
|
|
lastNotifications = saved.notifications;
|
|
notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false };
|
|
workPagination = saved.work_pagination || {};
|
|
lastContextSnapshot = saved;
|
|
hasContextSnapshot = true;
|
|
paintMyWork(saved);
|
|
setOfflineWorkMode(true);
|
|
const savedLabel = fmt(saved.saved_at);
|
|
if (outage) {
|
|
qs('#my-work-status').textContent = 'Outage · saved ' + savedLabel + ' · read-only';
|
|
offlineStatus.textContent = 'Server unavailable · showing private My Work saved ' + savedLabel + '. Live details and actions will return automatically.';
|
|
offlineWorkStatus.textContent = 'Outage · saved ' + savedLabel + ' · expires after 7 days.';
|
|
setStatus('Outage · saved snapshot');
|
|
} else {
|
|
qs('#my-work-status').textContent = 'Offline · saved ' + savedLabel + ' · read-only';
|
|
offlineStatus.textContent = 'Offline · showing private My Work saved ' + savedLabel + '. Live details and actions require reconnection.';
|
|
offlineWorkStatus.textContent = 'Offline · saved ' + savedLabel + ' · expires after 7 days.';
|
|
setStatus('Offline · saved snapshot');
|
|
}
|
|
offlineStatus.hidden = false;
|
|
return true;
|
|
}
|
|
function showOfflineStatus() {
|
|
activeFlushLogin = '';
|
|
offlineStatus.hidden = false;
|
|
setStatus('Offline');
|
|
if (!hasContextSnapshot) hydrateOfflineWork();
|
|
}
|
|
function reconnectLiveData() {
|
|
offlineStatus.hidden = true;
|
|
setOfflineWorkMode(false);
|
|
setStatus('Reconnecting…');
|
|
contextPoller.refresh({ force: true }).then(() => {
|
|
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
|
|
});
|
|
}
|
|
keepWorkOffline.addEventListener('change', () => {
|
|
offlineWorkStore.setEnabled(keepWorkOffline.checked);
|
|
if (keepWorkOffline.checked && liveMode && lastContextSnapshot) {
|
|
offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
|
notification_pagination:notificationPagination });
|
|
}
|
|
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
|
if (keepWorkOffline.checked) warmTodayOffline();
|
|
else offlineToday.cancel();
|
|
});
|
|
deliveryReceipts.addEventListener('change', async () => {
|
|
let enabled = deliveryReceipts.checked;
|
|
if (enabled && Notification.permission !== 'granted') {
|
|
enabled = await Notification.requestPermission() === 'granted';
|
|
}
|
|
deliveryReceipts.checked = enabled;
|
|
if (backgroundIssueSync && confirmedOwnerLogin) {
|
|
await backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled);
|
|
}
|
|
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
|
|
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
|
|
});
|
|
qs('#clear-offline-work').addEventListener('click', () => {
|
|
offlineWorkStore.clear();
|
|
offlineToday.cancel();
|
|
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
|
updateOfflineWorkControls('Offline work data cleared.');
|
|
});
|
|
qs('#retry-offline-today').addEventListener('click', () =>
|
|
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
|
|
);
|
|
updateOfflineWorkControls();
|
|
updateDeliveryReceiptControls();
|
|
if (!navigator.onLine) showOfflineStatus();
|
|
window.addEventListener('offline', showOfflineStatus);
|
|
window.addEventListener('online', reconnectLiveData);
|
|
|
|
qs('#refresh').addEventListener('click', load);
|
|
qs('#start-work-session').addEventListener('click', () => {
|
|
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
|
|
if (!sessionItems.length) {
|
|
qs('#my-work-action-status').textContent = 'No visible work to start.';
|
|
return;
|
|
}
|
|
workSession.start();
|
|
});
|
|
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
|
|
button.addEventListener('click', () => workSession.previous())
|
|
);
|
|
document.querySelectorAll('[data-work-session-next]').forEach(button =>
|
|
button.addEventListener('click', () => workSession.next())
|
|
);
|
|
qs('#load-more-notifications').addEventListener('click', () =>
|
|
notificationPager.loadMore(lastNotifications)
|
|
);
|
|
qs('#load-more-work').addEventListener('click', async () => {
|
|
const stream = activeWorkStreams().find(item => workPagination[item]?.has_more);
|
|
if (!stream || !lastContextSnapshot) return;
|
|
const button = qs('#load-more-work');
|
|
button.disabled = true;
|
|
const existing = stream === 'issue' ?
|
|
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
|
|
try {
|
|
const loaded = await workPager.loadMore(stream, existing);
|
|
if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus();
|
|
else button.focus();
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
qs('#bulk-mark-read').addEventListener('click', async () => {
|
|
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
|
|
const ids = allIds.slice(0, 50);
|
|
if (!ids.length || bulkMarkPending) return;
|
|
if (!bulkConfirmationPending) {
|
|
bulkConfirmationPending = true;
|
|
qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.';
|
|
renderMyWork();
|
|
return;
|
|
}
|
|
bulkConfirmationPending = false;
|
|
bulkMarkPending = true;
|
|
renderMyWork();
|
|
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids);
|
|
if (result) {
|
|
const marked = new Set(result.marked);
|
|
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
|
|
}
|
|
bulkMarkPending = false;
|
|
renderMyWork();
|
|
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
|
});
|
|
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
|
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
|
button.addEventListener('click', () => {
|
|
selectedWorkFilter = button.dataset.workFilter;
|
|
savedWorkFilter = selectedWorkFilter;
|
|
launchFilterResolved = true;
|
|
try {
|
|
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
|
|
} catch (e) {
|
|
console.warn('Could not persist My Work filter', e);
|
|
}
|
|
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
|
item.setAttribute('aria-pressed', String(item === button))
|
|
);
|
|
const selectedCount = button.querySelector('[data-work-count]').textContent;
|
|
qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')';
|
|
renderMyWork();
|
|
updateWorkPaginationControls();
|
|
});
|
|
});
|
|
function openDeliveryReceiptRoute() {
|
|
if (window.location.hash !== '#/my-work/drafts') return;
|
|
qs('[data-work-filter="draft"]').click();
|
|
qs('#my-work').scrollIntoView({block:'start'});
|
|
qs('#my-work').focus();
|
|
}
|
|
window.addEventListener('hashchange', openDeliveryReceiptRoute);
|
|
openDeliveryReceiptRoute();
|
|
qs('#work-milestone-filter').addEventListener('change', event => {
|
|
selectedWorkMilestone = event.target.value;
|
|
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
|
catch (e) { console.warn('Could not persist My Work milestone lane', e); }
|
|
renderMyWork();
|
|
if (workSession.active()) workSession.reconcile();
|
|
});
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('service-worker.js').catch(error =>
|
|
console.warn('Stackchain install support unavailable', error)
|
|
);
|
|
}
|
|
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
const isIosSafari = isIosDevice && /Safari/.test(navigator.userAgent) &&
|
|
!/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent);
|
|
createInstallApp({
|
|
window,
|
|
card: qs('#install-app-card'),
|
|
installButton: qs('#install-app'),
|
|
dismissButton: qs('#dismiss-install-app'),
|
|
guidance: qs('#install-app-guidance'),
|
|
status: qs('#install-app-status'),
|
|
storage: localStorage,
|
|
isStandalone: () => window.matchMedia('(display-mode: standalone)').matches ||
|
|
navigator.standalone === true,
|
|
isIosSafari: () => isIosSafari,
|
|
}).start();
|
|
contextPoller.start();
|
|
document.addEventListener('visibilitychange', () => {
|
|
contextPoller.setVisible(!document.hidden);
|
|
});
|
|
|
|
/* Widgets */
|
|
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
|
setInterval(widgetTick, 1000);
|
|
})();
|