@firstpick/pi-package-webui 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin/pi-webui.mjs +461 -0
- package/package.json +1 -1
- package/public/app.js +917 -8
- package/public/index.html +35 -3
- package/public/styles.css +486 -35
- package/tests/mobile-static.test.mjs +41 -3
package/public/app.js
CHANGED
|
@@ -15,6 +15,12 @@ const elements = {
|
|
|
15
15
|
serverOfflinePanel: $("#serverOfflinePanel"),
|
|
16
16
|
serverRestartPanel: $("#serverRestartPanel"),
|
|
17
17
|
serverRestartMessage: $("#serverRestartMessage"),
|
|
18
|
+
updateNotification: $("#updateNotification"),
|
|
19
|
+
updateNotificationTitle: $("#updateNotificationTitle"),
|
|
20
|
+
updateNotificationMessage: $("#updateNotificationMessage"),
|
|
21
|
+
updateNotificationDetail: $("#updateNotificationDetail"),
|
|
22
|
+
updateNotificationUpdateButton: $("#updateNotificationUpdateButton"),
|
|
23
|
+
updateNotificationDismissButton: $("#updateNotificationDismissButton"),
|
|
18
24
|
serverOfflineCommand: $("#serverOfflineCommand"),
|
|
19
25
|
serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
|
|
20
26
|
copyServerCommandButton: $("#copyServerCommandButton"),
|
|
@@ -87,6 +93,13 @@ const elements = {
|
|
|
87
93
|
gitPrStatus: $("#gitPrStatus"),
|
|
88
94
|
gitPrCancelButton: $("#gitPrCancelButton"),
|
|
89
95
|
gitPrCreateButton: $("#gitPrCreateButton"),
|
|
96
|
+
gitChangesDialog: $("#gitChangesDialog"),
|
|
97
|
+
gitChangesTitle: $("#gitChangesTitle"),
|
|
98
|
+
gitChangesSubtitle: $("#gitChangesSubtitle"),
|
|
99
|
+
gitChangesStatus: $("#gitChangesStatus"),
|
|
100
|
+
gitChangesBody: $("#gitChangesBody"),
|
|
101
|
+
gitChangesRefreshButton: $("#gitChangesRefreshButton"),
|
|
102
|
+
gitChangesCloseButton: $("#gitChangesCloseButton"),
|
|
90
103
|
modelSelect: $("#modelSelect"),
|
|
91
104
|
setModelButton: $("#setModelButton"),
|
|
92
105
|
thinkingSelect: $("#thinkingSelect"),
|
|
@@ -213,6 +226,9 @@ let foregroundReconcileTimer = null;
|
|
|
213
226
|
let eventSource = null;
|
|
214
227
|
let activeDialog = null;
|
|
215
228
|
let activeGitPrDialogResolve = null;
|
|
229
|
+
let gitChangesState = { loading: false, error: "", data: null, tabId: null };
|
|
230
|
+
let gitChangesRequestSerial = 0;
|
|
231
|
+
const gitChangesUntrackedContentRequests = new Set();
|
|
216
232
|
let nativeCommandTabId = null;
|
|
217
233
|
let pathPickerState = null;
|
|
218
234
|
let firstTerminalCwdPromptShown = false;
|
|
@@ -251,6 +267,10 @@ let refreshCodexUsageTimer = null;
|
|
|
251
267
|
let codexUsageRenderTimer = null;
|
|
252
268
|
let backendOffline = false;
|
|
253
269
|
let serverRestartInProgress = false;
|
|
270
|
+
let updateRequestInProgress = false;
|
|
271
|
+
let latestUpdateStatus = null;
|
|
272
|
+
let updateStatusRefreshTimer = null;
|
|
273
|
+
let updateNotificationHideTimer = null;
|
|
254
274
|
let backendOfflineNoticeShown = false;
|
|
255
275
|
let latestMessages = [];
|
|
256
276
|
let promptHistoryByTab = new Map();
|
|
@@ -295,6 +315,9 @@ let chatUserScrollIntentUntil = 0;
|
|
|
295
315
|
let mobileFooterExpanded = false;
|
|
296
316
|
let footerModelPickerOpen = false;
|
|
297
317
|
let footerThinkingPickerOpen = false;
|
|
318
|
+
let footerBranchPickerOpen = false;
|
|
319
|
+
let footerBranchPickerState = { loading: false, error: "", branches: [], current: "", root: "", switching: "", tabId: null };
|
|
320
|
+
let footerBranchPickerRequestSerial = 0;
|
|
298
321
|
let publishMenuOpen = false;
|
|
299
322
|
let maxVisualViewportHeight = 0;
|
|
300
323
|
let abortRequestInFlight = false;
|
|
@@ -311,6 +334,7 @@ const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed";
|
|
|
311
334
|
const TAB_STORAGE_KEY = "pi-webui-active-tab";
|
|
312
335
|
const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
|
|
313
336
|
const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
|
|
337
|
+
const UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY = "pi-webui-update-notification-dismissed";
|
|
314
338
|
const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
|
|
315
339
|
const BUSY_PROMPT_BEHAVIOR_STORAGE_KEY = "pi-webui-busy-prompt-behavior";
|
|
316
340
|
const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1";
|
|
@@ -330,6 +354,7 @@ const GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui";
|
|
|
330
354
|
const GIT_FOOTER_WEBUI_PAYLOAD_TYPE = "firstpick.git-footer-status.footer";
|
|
331
355
|
const GIT_FOOTER_WEBUI_PAYLOAD_VERSION = 1;
|
|
332
356
|
const GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY = "pi-webui-git-footer-webui-payload-cache";
|
|
357
|
+
const GIT_CHANGES_RENDER_ROW_LIMIT = 4000;
|
|
333
358
|
const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
|
|
334
359
|
const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
|
|
335
360
|
const PROMPT_LIST_STORAGE_KEY = "pi-webui-prompt-lists";
|
|
@@ -360,6 +385,8 @@ const CHAT_PROGRAMMATIC_SCROLL_GRACE_MS = 500;
|
|
|
360
385
|
const CHAT_USER_SCROLL_INTENT_MS = 700;
|
|
361
386
|
const CODEX_USAGE_REFRESH_MS = 5 * 60 * 1000;
|
|
362
387
|
const CODEX_USAGE_RENDER_TICK_MS = 30 * 1000;
|
|
388
|
+
const UPDATE_STATUS_REFRESH_MS = 6 * 60 * 60 * 1000;
|
|
389
|
+
const UPDATE_STATUS_INITIAL_DELAY_MS = 1800;
|
|
363
390
|
const RUN_INDICATOR_TICK_MS = 1000;
|
|
364
391
|
const RUN_INDICATOR_START_GRACE_MS = 2500;
|
|
365
392
|
const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
|
|
@@ -1467,10 +1494,11 @@ function updateComposerModeButtons() {
|
|
|
1467
1494
|
}
|
|
1468
1495
|
|
|
1469
1496
|
function isFooterPickerOpen() {
|
|
1470
|
-
return footerModelPickerOpen || footerThinkingPickerOpen;
|
|
1497
|
+
return footerModelPickerOpen || footerThinkingPickerOpen || footerBranchPickerOpen;
|
|
1471
1498
|
}
|
|
1472
1499
|
|
|
1473
1500
|
function footerActivePickerTarget() {
|
|
1501
|
+
if (footerBranchPickerOpen) return elements.statusBar.querySelector(".footer-branch.footer-meta-action");
|
|
1474
1502
|
if (footerThinkingPickerOpen) return elements.statusBar.querySelector(".footer-thinking.footer-meta-action");
|
|
1475
1503
|
if (footerModelPickerOpen) return elements.statusBar.querySelector(".footer-model.footer-meta-action, .footer-tui-model");
|
|
1476
1504
|
return null;
|
|
@@ -1521,6 +1549,7 @@ function setMobileFooterExpanded(expanded) {
|
|
|
1521
1549
|
if (mobileFooterExpanded && isFooterPickerOpen()) {
|
|
1522
1550
|
footerModelPickerOpen = false;
|
|
1523
1551
|
footerThinkingPickerOpen = false;
|
|
1552
|
+
footerBranchPickerOpen = false;
|
|
1524
1553
|
document.body.classList.remove("footer-model-picker-open");
|
|
1525
1554
|
elements.statusBar.querySelectorAll(".footer-model-picker").forEach((node) => node.remove());
|
|
1526
1555
|
}
|
|
@@ -1624,6 +1653,7 @@ function updateVisualViewportVars() {
|
|
|
1624
1653
|
setMobileTabsExpanded(false);
|
|
1625
1654
|
setMobileFooterExpanded(false);
|
|
1626
1655
|
setFooterModelPickerOpen(false);
|
|
1656
|
+
setFooterBranchPickerOpen(false);
|
|
1627
1657
|
syncMobileChatToBottomForInput();
|
|
1628
1658
|
}
|
|
1629
1659
|
updateFooterModelPickerPosition();
|
|
@@ -1698,6 +1728,7 @@ function setServerRestartOverlay(active, message = "Waiting for the server to co
|
|
|
1698
1728
|
document.body.classList.toggle("server-restarting", serverRestartInProgress);
|
|
1699
1729
|
if (elements.serverRestartPanel) elements.serverRestartPanel.hidden = !serverRestartInProgress;
|
|
1700
1730
|
if (elements.serverRestartMessage) elements.serverRestartMessage.textContent = message;
|
|
1731
|
+
if (serverRestartInProgress) hideUpdateNotification();
|
|
1701
1732
|
if (serverRestartInProgress && elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = true;
|
|
1702
1733
|
}
|
|
1703
1734
|
|
|
@@ -1708,6 +1739,7 @@ function setBackendOffline(offline, error) {
|
|
|
1708
1739
|
if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !showOfflinePanel;
|
|
1709
1740
|
renderServerOfflinePanel();
|
|
1710
1741
|
if (backendOffline) {
|
|
1742
|
+
hideUpdateNotification();
|
|
1711
1743
|
if (!serverRestartInProgress && !backendOfflineNoticeShown) {
|
|
1712
1744
|
backendOfflineNoticeShown = true;
|
|
1713
1745
|
addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
|
|
@@ -1950,6 +1982,169 @@ async function refreshWebuiVersion() {
|
|
|
1950
1982
|
setWebuiDevServer(isWebuiDevMetadata(health));
|
|
1951
1983
|
}
|
|
1952
1984
|
|
|
1985
|
+
function packageUpdateText(label, status = {}) {
|
|
1986
|
+
const current = formatWebuiVersion(status.currentVersion || "");
|
|
1987
|
+
const latest = formatWebuiVersion(status.latestVersion || "");
|
|
1988
|
+
if (current && latest) return `${label} ${current} → ${latest}`;
|
|
1989
|
+
if (latest) return `${label} ${latest}`;
|
|
1990
|
+
return label;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function updateNotificationItems(status = latestUpdateStatus) {
|
|
1994
|
+
const items = [];
|
|
1995
|
+
if (status?.pi?.updateAvailable) items.push(packageUpdateText("Pi", status.pi));
|
|
1996
|
+
if (status?.webui?.updateAvailable) items.push(packageUpdateText("Web UI", status.webui));
|
|
1997
|
+
return items;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function updateNotificationDismissKey(status = latestUpdateStatus) {
|
|
2001
|
+
const parts = [status?.pi?.latestVersion, status?.webui?.latestVersion]
|
|
2002
|
+
.map((value) => String(value || "").trim())
|
|
2003
|
+
.filter(Boolean);
|
|
2004
|
+
return parts.length ? parts.join("|") : "";
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function storedDismissedUpdateKey() {
|
|
2008
|
+
try {
|
|
2009
|
+
return localStorage.getItem(UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY) || "";
|
|
2010
|
+
} catch {
|
|
2011
|
+
return "";
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
function rememberDismissedUpdateKey(key) {
|
|
2016
|
+
if (!key) return;
|
|
2017
|
+
try {
|
|
2018
|
+
localStorage.setItem(UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY, key);
|
|
2019
|
+
} catch {
|
|
2020
|
+
// Ignore private-mode storage failures.
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function hideUpdateNotification({ remember = false } = {}) {
|
|
2025
|
+
const panel = elements.updateNotification;
|
|
2026
|
+
if (!panel) return;
|
|
2027
|
+
clearTimeout(updateNotificationHideTimer);
|
|
2028
|
+
if (remember) rememberDismissedUpdateKey(updateNotificationDismissKey());
|
|
2029
|
+
panel.classList.remove("show");
|
|
2030
|
+
updateNotificationHideTimer = setTimeout(() => {
|
|
2031
|
+
panel.hidden = true;
|
|
2032
|
+
}, 360);
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function renderUpdateNotification(status = latestUpdateStatus, { force = false } = {}) {
|
|
2036
|
+
const panel = elements.updateNotification;
|
|
2037
|
+
if (!panel) return;
|
|
2038
|
+
latestUpdateStatus = status || latestUpdateStatus;
|
|
2039
|
+
const items = updateNotificationItems(latestUpdateStatus);
|
|
2040
|
+
const dismissKey = updateNotificationDismissKey(latestUpdateStatus);
|
|
2041
|
+
const shouldShow = !!latestUpdateStatus?.updateAvailable && items.length > 0 && !updateRequestInProgress;
|
|
2042
|
+
if (!shouldShow || (!force && dismissKey && storedDismissedUpdateKey() === dismissKey)) {
|
|
2043
|
+
hideUpdateNotification();
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
const canRunUpdate = latestUpdateStatus.canRunUpdate !== false;
|
|
2048
|
+
if (elements.updateNotificationTitle) elements.updateNotificationTitle.textContent = items.length === 1 ? `${items[0]} available` : "Pi updates available";
|
|
2049
|
+
if (elements.updateNotificationMessage) {
|
|
2050
|
+
elements.updateNotificationMessage.textContent = canRunUpdate
|
|
2051
|
+
? "Run pi update now, then restart this Web UI server automatically."
|
|
2052
|
+
: "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
|
|
2053
|
+
}
|
|
2054
|
+
const details = [
|
|
2055
|
+
items.join(" · "),
|
|
2056
|
+
latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; pi update updates installed Pi packages, not this checkout." : "",
|
|
2057
|
+
latestUpdateStatus.packages?.note || "",
|
|
2058
|
+
].filter(Boolean).join(" ");
|
|
2059
|
+
if (elements.updateNotificationDetail) elements.updateNotificationDetail.textContent = details;
|
|
2060
|
+
if (elements.updateNotificationUpdateButton) {
|
|
2061
|
+
elements.updateNotificationUpdateButton.hidden = !canRunUpdate;
|
|
2062
|
+
elements.updateNotificationUpdateButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
|
|
2063
|
+
elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update & restart";
|
|
2064
|
+
}
|
|
2065
|
+
clearTimeout(updateNotificationHideTimer);
|
|
2066
|
+
panel.hidden = false;
|
|
2067
|
+
requestAnimationFrame(() => panel.classList.add("show"));
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
async function refreshUpdateStatus({ force = false, notify = true } = {}) {
|
|
2071
|
+
const path = force ? "/api/update-status?refresh=1" : "/api/update-status";
|
|
2072
|
+
const response = await api(path, { scoped: false });
|
|
2073
|
+
latestUpdateStatus = response.data || null;
|
|
2074
|
+
if (notify) renderUpdateNotification(latestUpdateStatus);
|
|
2075
|
+
return latestUpdateStatus;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
function scheduleUpdateStatusRefresh() {
|
|
2079
|
+
clearTimeout(updateStatusRefreshTimer);
|
|
2080
|
+
updateStatusRefreshTimer = setTimeout(() => {
|
|
2081
|
+
updateStatusRefreshTimer = null;
|
|
2082
|
+
refreshUpdateStatus({ force: true }).catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
|
|
2083
|
+
scheduleUpdateStatusRefresh();
|
|
2084
|
+
}, UPDATE_STATUS_REFRESH_MS);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function initializeUpdateNotifications() {
|
|
2088
|
+
setTimeout(() => {
|
|
2089
|
+
refreshUpdateStatus().catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
|
|
2090
|
+
scheduleUpdateStatusRefresh();
|
|
2091
|
+
}, UPDATE_STATUS_INITIAL_DELAY_MS);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
function piUpdateConfirmationText() {
|
|
2095
|
+
const items = updateNotificationItems();
|
|
2096
|
+
const workingWarning = hasWorkingTab() ? "\n\nOne or more Pi tabs look busy or blocked. Finish or abort in-flight work before updating if you need to preserve it." : "";
|
|
2097
|
+
const versionText = items.length ? `\n\nDetected update: ${items.join(" · ")}.` : "";
|
|
2098
|
+
return `Run pi update now?${versionText}\n\nThis will run \"pi update\" on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
async function runPiUpdateAndRestart() {
|
|
2102
|
+
if (updateRequestInProgress) return;
|
|
2103
|
+
if (latestUpdateStatus?.canRunUpdate === false) {
|
|
2104
|
+
addEvent("Pi update can only be started from localhost on the Web UI host", "warn");
|
|
2105
|
+
renderUpdateNotification(latestUpdateStatus, { force: true });
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
if (!confirm(piUpdateConfirmationText())) return;
|
|
2109
|
+
|
|
2110
|
+
updateRequestInProgress = true;
|
|
2111
|
+
hideUpdateNotification();
|
|
2112
|
+
setServerActionBusy("Updating…");
|
|
2113
|
+
setServerActionStatus("Running pi update. The server will restart after the update completes…", "warn");
|
|
2114
|
+
setServerRestartOverlay(true, "Running pi update. The server will restart after the update completes…");
|
|
2115
|
+
try {
|
|
2116
|
+
await api("/api/update", { method: "POST", scoped: false });
|
|
2117
|
+
addEvent("Pi update completed; Pi Web UI server restart requested", "warn");
|
|
2118
|
+
} catch (error) {
|
|
2119
|
+
if (!error?.backendOffline) {
|
|
2120
|
+
updateRequestInProgress = false;
|
|
2121
|
+
setServerRestartOverlay(false);
|
|
2122
|
+
resetServerActionControls();
|
|
2123
|
+
const message = error.message || String(error);
|
|
2124
|
+
setServerActionStatus(message, "error");
|
|
2125
|
+
addEvent(message, "error");
|
|
2126
|
+
renderUpdateNotification(latestUpdateStatus, { force: true });
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
addEvent("Pi Web UI server connection dropped during update restart request", "warn");
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
setBackendOffline(true, new Error("update requested from side panel"));
|
|
2133
|
+
const restarted = await waitForServerRestart();
|
|
2134
|
+
updateRequestInProgress = false;
|
|
2135
|
+
resetServerActionControls();
|
|
2136
|
+
if (restarted) {
|
|
2137
|
+
hideUpdateNotification({ remember: true });
|
|
2138
|
+
setServerActionStatus("Updated, restarted, and reconnected.", "success");
|
|
2139
|
+
refreshUpdateStatus({ force: true, notify: false }).catch(() => {});
|
|
2140
|
+
} else {
|
|
2141
|
+
setServerRestartOverlay(false);
|
|
2142
|
+
setBackendOffline(true, new Error("update restart reconnect timed out"));
|
|
2143
|
+
setServerActionStatus("Update completed, but the server did not reconnect automatically.", "error");
|
|
2144
|
+
addEvent("Pi Web UI server did not come back online after update request", "error");
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
1953
2148
|
function formatBytes(bytes) {
|
|
1954
2149
|
const value = Number(bytes) || 0;
|
|
1955
2150
|
if (value < 1024) return `${value} B`;
|
|
@@ -3398,7 +3593,7 @@ function restoreActiveDraft() {
|
|
|
3398
3593
|
|
|
3399
3594
|
function focusPromptInput({ defer = false } = {}) {
|
|
3400
3595
|
const focus = () => {
|
|
3401
|
-
if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
|
|
3596
|
+
if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.gitChangesDialog?.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
|
|
3402
3597
|
try {
|
|
3403
3598
|
elements.promptInput.focus({ preventScroll: true });
|
|
3404
3599
|
} catch {
|
|
@@ -3764,6 +3959,8 @@ async function switchTab(tabId) {
|
|
|
3764
3959
|
setMobileTabsExpanded(false);
|
|
3765
3960
|
footerModelPickerOpen = false;
|
|
3766
3961
|
footerThinkingPickerOpen = false;
|
|
3962
|
+
footerBranchPickerOpen = false;
|
|
3963
|
+
footerBranchPickerRequestSerial += 1;
|
|
3767
3964
|
saveActiveDraft();
|
|
3768
3965
|
const tabContext = setActiveTabId(tabId, { remember: true });
|
|
3769
3966
|
resetActiveTabUi();
|
|
@@ -4870,6 +5067,12 @@ function renderGitFooterPayloadMeta(chip, tab) {
|
|
|
4870
5067
|
if (chip.key === "cwd" && tab) {
|
|
4871
5068
|
options.onClick = changeActiveTabCwd;
|
|
4872
5069
|
action = `Click to change the working directory for ${tab.title}.`;
|
|
5070
|
+
} else if (chip.key === "git" && chip.value !== "no repo") {
|
|
5071
|
+
options.onClick = () => setFooterBranchPickerOpen(!footerBranchPickerOpen);
|
|
5072
|
+
action = "Click to switch to another local branch.";
|
|
5073
|
+
} else if (chip.key === "changes") {
|
|
5074
|
+
options.onClick = openGitChangesDialog;
|
|
5075
|
+
action = "Click to view the current git diff.";
|
|
4873
5076
|
} else if (chip.key === "model") {
|
|
4874
5077
|
options.onClick = () => setFooterModelPickerOpen(!footerModelPickerOpen);
|
|
4875
5078
|
action = "Click to choose another model.";
|
|
@@ -4880,6 +5083,10 @@ function renderGitFooterPayloadMeta(chip, tab) {
|
|
|
4880
5083
|
options.title = gitFooterPayloadTooltip(chip, { action });
|
|
4881
5084
|
options.tooltipAlign = gitFooterTooltipAlign(chip);
|
|
4882
5085
|
const node = footerMeta(chip.label, chip.value, footerMetaClassForPayload(chip), options);
|
|
5086
|
+
if (chip.key === "git" && options.onClick) {
|
|
5087
|
+
node.setAttribute("aria-haspopup", "listbox");
|
|
5088
|
+
node.setAttribute("aria-expanded", footerBranchPickerOpen ? "true" : "false");
|
|
5089
|
+
}
|
|
4883
5090
|
return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
|
|
4884
5091
|
}
|
|
4885
5092
|
|
|
@@ -4905,10 +5112,450 @@ function renderGitFooterPayload(payload) {
|
|
|
4905
5112
|
elements.statusBar.append(row1, row2);
|
|
4906
5113
|
if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
|
|
4907
5114
|
if (footerThinkingPickerOpen) elements.statusBar.append(renderFooterThinkingPicker());
|
|
5115
|
+
if (footerBranchPickerOpen) elements.statusBar.append(renderFooterBranchPicker());
|
|
4908
5116
|
setMobileFooterExpanded(mobileFooterExpanded);
|
|
4909
5117
|
updateFooterModelPickerPosition();
|
|
4910
5118
|
}
|
|
4911
5119
|
|
|
5120
|
+
function cleanGitDiffPath(value = "") {
|
|
5121
|
+
let text = String(value || "").trim();
|
|
5122
|
+
if (!text || text === "/dev/null") return "";
|
|
5123
|
+
if ((text.startsWith("a/") || text.startsWith("b/")) && text.length > 2) text = text.slice(2);
|
|
5124
|
+
return text;
|
|
5125
|
+
}
|
|
5126
|
+
|
|
5127
|
+
function gitDiffPathFromHeader(line) {
|
|
5128
|
+
const match = String(line || "").match(/^diff --git\s+(.+?)\s+(.+)$/);
|
|
5129
|
+
return cleanGitDiffPath(match?.[2] || match?.[1] || "");
|
|
5130
|
+
}
|
|
5131
|
+
|
|
5132
|
+
function parseGitUnifiedDiff(diffText = "") {
|
|
5133
|
+
const normalized = String(diffText || "").replace(/\r\n?/g, "\n");
|
|
5134
|
+
const lines = normalized.split("\n");
|
|
5135
|
+
const files = [];
|
|
5136
|
+
let file = null;
|
|
5137
|
+
let hunk = null;
|
|
5138
|
+
let oldLineNumber = 0;
|
|
5139
|
+
let newLineNumber = 0;
|
|
5140
|
+
let deleteBuffer = [];
|
|
5141
|
+
let addBuffer = [];
|
|
5142
|
+
|
|
5143
|
+
const flushChangeRows = () => {
|
|
5144
|
+
if (!hunk || (!deleteBuffer.length && !addBuffer.length)) return;
|
|
5145
|
+
if (file) {
|
|
5146
|
+
file.deletions += deleteBuffer.length;
|
|
5147
|
+
file.additions += addBuffer.length;
|
|
5148
|
+
}
|
|
5149
|
+
const rowCount = Math.max(deleteBuffer.length, addBuffer.length);
|
|
5150
|
+
for (let i = 0; i < rowCount; i++) {
|
|
5151
|
+
const left = deleteBuffer[i] || null;
|
|
5152
|
+
const right = addBuffer[i] || null;
|
|
5153
|
+
hunk.rows.push({
|
|
5154
|
+
type: left && right ? "changed" : left ? "removed" : "added",
|
|
5155
|
+
oldNumber: left?.number ?? "",
|
|
5156
|
+
newNumber: right?.number ?? "",
|
|
5157
|
+
left: left?.text ?? "",
|
|
5158
|
+
right: right?.text ?? "",
|
|
5159
|
+
});
|
|
5160
|
+
}
|
|
5161
|
+
deleteBuffer = [];
|
|
5162
|
+
addBuffer = [];
|
|
5163
|
+
};
|
|
5164
|
+
|
|
5165
|
+
const finishFile = () => {
|
|
5166
|
+
flushChangeRows();
|
|
5167
|
+
if (!file) return;
|
|
5168
|
+
file.path = file.newPath || file.oldPath || file.headerPath || "diff";
|
|
5169
|
+
files.push(file);
|
|
5170
|
+
file = null;
|
|
5171
|
+
hunk = null;
|
|
5172
|
+
};
|
|
5173
|
+
|
|
5174
|
+
for (let index = 0; index < lines.length; index++) {
|
|
5175
|
+
const line = lines[index] || "";
|
|
5176
|
+
if (index === lines.length - 1 && !line && normalized.endsWith("\n")) continue;
|
|
5177
|
+
|
|
5178
|
+
if (line.startsWith("diff --git ")) {
|
|
5179
|
+
finishFile();
|
|
5180
|
+
file = { path: "", oldPath: "", newPath: "", headerPath: gitDiffPathFromHeader(line), headers: [line], hunks: [], additions: 0, deletions: 0 };
|
|
5181
|
+
continue;
|
|
5182
|
+
}
|
|
5183
|
+
|
|
5184
|
+
if (!file) {
|
|
5185
|
+
if (!line.trim()) continue;
|
|
5186
|
+
file = { path: "diff", oldPath: "", newPath: "", headerPath: "diff", headers: [], hunks: [], additions: 0, deletions: 0 };
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5189
|
+
if (line.startsWith("@@ ")) {
|
|
5190
|
+
flushChangeRows();
|
|
5191
|
+
const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
5192
|
+
oldLineNumber = Number.parseInt(match?.[1] || "0", 10) || 0;
|
|
5193
|
+
newLineNumber = Number.parseInt(match?.[2] || "0", 10) || 0;
|
|
5194
|
+
hunk = { header: line, rows: [] };
|
|
5195
|
+
file.hunks.push(hunk);
|
|
5196
|
+
continue;
|
|
5197
|
+
}
|
|
5198
|
+
|
|
5199
|
+
if (!hunk) {
|
|
5200
|
+
file.headers.push(line);
|
|
5201
|
+
if (line.startsWith("--- ")) file.oldPath = cleanGitDiffPath(line.slice(4));
|
|
5202
|
+
if (line.startsWith("+++ ")) file.newPath = cleanGitDiffPath(line.slice(4));
|
|
5203
|
+
continue;
|
|
5204
|
+
}
|
|
5205
|
+
|
|
5206
|
+
if (line.startsWith("-")) {
|
|
5207
|
+
deleteBuffer.push({ number: oldLineNumber, text: line.slice(1) });
|
|
5208
|
+
oldLineNumber += 1;
|
|
5209
|
+
continue;
|
|
5210
|
+
}
|
|
5211
|
+
if (line.startsWith("+")) {
|
|
5212
|
+
addBuffer.push({ number: newLineNumber, text: line.slice(1) });
|
|
5213
|
+
newLineNumber += 1;
|
|
5214
|
+
continue;
|
|
5215
|
+
}
|
|
5216
|
+
|
|
5217
|
+
flushChangeRows();
|
|
5218
|
+
if (line.startsWith(" ")) {
|
|
5219
|
+
const text = line.slice(1);
|
|
5220
|
+
hunk.rows.push({ type: "context", oldNumber: oldLineNumber, newNumber: newLineNumber, left: text, right: text });
|
|
5221
|
+
oldLineNumber += 1;
|
|
5222
|
+
newLineNumber += 1;
|
|
5223
|
+
} else if (line.startsWith("\\")) {
|
|
5224
|
+
hunk.rows.push({ type: "meta", oldNumber: "", newNumber: "", left: line, right: line });
|
|
5225
|
+
} else {
|
|
5226
|
+
hunk.rows.push({ type: "meta", oldNumber: "", newNumber: "", left: line, right: line });
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
|
|
5230
|
+
finishFile();
|
|
5231
|
+
return files;
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
function gitChangesChip(label, value, className = "") {
|
|
5235
|
+
const chip = make("div", `git-changes-chip ${className}`.trim());
|
|
5236
|
+
chip.append(make("span", "git-changes-chip-label", label), make("span", "git-changes-chip-value", String(value ?? "—")));
|
|
5237
|
+
return chip;
|
|
5238
|
+
}
|
|
5239
|
+
|
|
5240
|
+
function renderGitChangesOverview(data) {
|
|
5241
|
+
const summary = data?.summary || {};
|
|
5242
|
+
const untrackedCount = Array.isArray(data?.untracked) ? data.untracked.length : Number(summary.untracked || 0);
|
|
5243
|
+
const overview = make("div", "git-changes-overview");
|
|
5244
|
+
overview.append(
|
|
5245
|
+
gitChangesChip("repo", data?.root || "—", "wide"),
|
|
5246
|
+
gitChangesChip("branch", data?.branch || "detached"),
|
|
5247
|
+
gitChangesChip("staged", summary.staged || 0, "success"),
|
|
5248
|
+
gitChangesChip("modified", summary.unstaged || 0, "warning"),
|
|
5249
|
+
gitChangesChip("untracked", untrackedCount, "muted"),
|
|
5250
|
+
gitChangesChip("conflicts", summary.conflicted || 0, (summary.conflicted || 0) > 0 ? "danger" : "muted"),
|
|
5251
|
+
);
|
|
5252
|
+
return overview;
|
|
5253
|
+
}
|
|
5254
|
+
|
|
5255
|
+
function renderGitDiffRow(row) {
|
|
5256
|
+
const node = make("div", `git-diff-row ${row.type || "context"}`.trim());
|
|
5257
|
+
node.append(
|
|
5258
|
+
make("span", "git-diff-line-number old", row.oldNumber === "" ? "" : String(row.oldNumber)),
|
|
5259
|
+
make("code", "git-diff-line old", row.left ?? ""),
|
|
5260
|
+
make("span", "git-diff-line-number new", row.newNumber === "" ? "" : String(row.newNumber)),
|
|
5261
|
+
make("code", "git-diff-line new", row.right ?? ""),
|
|
5262
|
+
);
|
|
5263
|
+
return node;
|
|
5264
|
+
}
|
|
5265
|
+
|
|
5266
|
+
function renderGitDiffGrid(file) {
|
|
5267
|
+
const grid = make("div", "git-diff-grid");
|
|
5268
|
+
const rowLimit = file.renderRowLimit ?? GIT_CHANGES_RENDER_ROW_LIMIT;
|
|
5269
|
+
let renderedRows = 0;
|
|
5270
|
+
let truncated = false;
|
|
5271
|
+
for (const hunk of file.hunks || []) {
|
|
5272
|
+
if (renderedRows >= rowLimit) {
|
|
5273
|
+
truncated = true;
|
|
5274
|
+
break;
|
|
5275
|
+
}
|
|
5276
|
+
grid.append(renderGitDiffRow({ type: "hunk", oldNumber: "", newNumber: "", left: hunk.header, right: hunk.header }));
|
|
5277
|
+
renderedRows += 1;
|
|
5278
|
+
for (const row of hunk.rows || []) {
|
|
5279
|
+
if (renderedRows >= rowLimit) {
|
|
5280
|
+
truncated = true;
|
|
5281
|
+
break;
|
|
5282
|
+
}
|
|
5283
|
+
grid.append(renderGitDiffRow(row));
|
|
5284
|
+
renderedRows += 1;
|
|
5285
|
+
}
|
|
5286
|
+
if (truncated) break;
|
|
5287
|
+
}
|
|
5288
|
+
if (truncated) {
|
|
5289
|
+
grid.append(renderGitDiffRow({ type: "meta", oldNumber: "", newNumber: "", left: `Diff preview truncated after ${rowLimit} rows.`, right: "Use git diff in the terminal for the full output." }));
|
|
5290
|
+
}
|
|
5291
|
+
return grid;
|
|
5292
|
+
}
|
|
5293
|
+
|
|
5294
|
+
function renderGitDiffFile(file) {
|
|
5295
|
+
const details = make("details", `git-diff-file ${file.className || ""}`.trim());
|
|
5296
|
+
details.open = true;
|
|
5297
|
+
details.dataset.gitDiffFile = file.path || "diff";
|
|
5298
|
+
const summary = make("summary", "git-diff-file-summary");
|
|
5299
|
+
summary.append(
|
|
5300
|
+
make("span", "git-diff-file-name", file.path || "diff"),
|
|
5301
|
+
make("span", "git-diff-file-stats", file.statsText || `+${file.additions || 0} −${file.deletions || 0}`),
|
|
5302
|
+
);
|
|
5303
|
+
details.append(summary);
|
|
5304
|
+
if (file.hunks?.length) {
|
|
5305
|
+
details.append(renderGitDiffGrid(file));
|
|
5306
|
+
} else {
|
|
5307
|
+
details.append(make("pre", "git-diff-raw", (file.headers || []).join("\n") || "No textual diff for this file."));
|
|
5308
|
+
}
|
|
5309
|
+
return details;
|
|
5310
|
+
}
|
|
5311
|
+
|
|
5312
|
+
function renderGitDiffSection(section, files) {
|
|
5313
|
+
const key = String(section?.key || "diff").replace(/[^a-z0-9_-]/gi, "-");
|
|
5314
|
+
const wrapper = make("section", `git-diff-section git-diff-section-${key}`);
|
|
5315
|
+
const header = make("div", "git-diff-section-heading");
|
|
5316
|
+
header.append(
|
|
5317
|
+
make("div", "git-diff-section-title", section?.label || "Git diff"),
|
|
5318
|
+
make("div", "git-diff-section-meta", `${files.length} file${files.length === 1 ? "" : "s"} · ${section?.command || "git diff"}`),
|
|
5319
|
+
);
|
|
5320
|
+
wrapper.append(header, ...files.map(renderGitDiffFile));
|
|
5321
|
+
return wrapper;
|
|
5322
|
+
}
|
|
5323
|
+
|
|
5324
|
+
function normalizeGitUntrackedEntry(value) {
|
|
5325
|
+
if (typeof value === "string") return { path: value, size: 0, binary: false, content: "", contentMissing: true };
|
|
5326
|
+
if (!value || typeof value !== "object") return null;
|
|
5327
|
+
const path = String(value.path || "").trim();
|
|
5328
|
+
if (!path) return null;
|
|
5329
|
+
const hasContent = Object.prototype.hasOwnProperty.call(value, "content");
|
|
5330
|
+
const binary = value.binary === true;
|
|
5331
|
+
const error = value.error ? String(value.error) : "";
|
|
5332
|
+
return {
|
|
5333
|
+
path,
|
|
5334
|
+
size: Number(value.size || 0) || 0,
|
|
5335
|
+
binary,
|
|
5336
|
+
content: hasContent && typeof value.content === "string" ? value.content : "",
|
|
5337
|
+
contentMissing: !hasContent && !binary && !error,
|
|
5338
|
+
error,
|
|
5339
|
+
};
|
|
5340
|
+
}
|
|
5341
|
+
|
|
5342
|
+
function gitUntrackedEntries(untracked) {
|
|
5343
|
+
return Array.isArray(untracked) ? untracked.map(normalizeGitUntrackedEntry).filter(Boolean) : [];
|
|
5344
|
+
}
|
|
5345
|
+
|
|
5346
|
+
function gitUntrackedContentLines(content = "") {
|
|
5347
|
+
const normalized = String(content || "").replace(/\r\n?/g, "\n");
|
|
5348
|
+
if (!normalized) return [];
|
|
5349
|
+
const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
|
|
5350
|
+
return withoutFinalNewline ? withoutFinalNewline.split("\n") : [""];
|
|
5351
|
+
}
|
|
5352
|
+
|
|
5353
|
+
function gitUntrackedEntryToDiffFile(entry) {
|
|
5354
|
+
const lines = gitUntrackedContentLines(entry.content);
|
|
5355
|
+
return {
|
|
5356
|
+
path: entry.path,
|
|
5357
|
+
className: "git-untracked-full-file",
|
|
5358
|
+
additions: lines.length,
|
|
5359
|
+
deletions: 0,
|
|
5360
|
+
statsText: `${entry.binary ? "binary" : `+${lines.length}`} · ${formatBytes(entry.size)}`,
|
|
5361
|
+
renderRowLimit: Number.POSITIVE_INFINITY,
|
|
5362
|
+
headers: lines.length ? [] : ["Empty untracked file."],
|
|
5363
|
+
hunks: lines.length ? [{
|
|
5364
|
+
header: `@@ -0,0 +1,${lines.length} @@`,
|
|
5365
|
+
rows: lines.map((line, index) => ({ type: "added", oldNumber: "", newNumber: index + 1, left: "", right: line })),
|
|
5366
|
+
}] : [],
|
|
5367
|
+
};
|
|
5368
|
+
}
|
|
5369
|
+
|
|
5370
|
+
function renderGitUntrackedRawFile(entry) {
|
|
5371
|
+
const details = make("details", "git-diff-file git-untracked-full-file");
|
|
5372
|
+
details.open = true;
|
|
5373
|
+
details.dataset.gitDiffFile = entry.path;
|
|
5374
|
+
const summary = make("summary", "git-diff-file-summary");
|
|
5375
|
+
summary.append(
|
|
5376
|
+
make("span", "git-diff-file-name", entry.path),
|
|
5377
|
+
make("span", "git-diff-file-stats", entry.error ? "unreadable" : `binary · ${formatBytes(entry.size)}`),
|
|
5378
|
+
);
|
|
5379
|
+
details.append(summary, make("pre", "git-diff-raw", entry.error || "Binary untracked file; text preview unavailable."));
|
|
5380
|
+
return details;
|
|
5381
|
+
}
|
|
5382
|
+
|
|
5383
|
+
function renderGitUntrackedLoadingFile(entry) {
|
|
5384
|
+
const details = make("details", "git-diff-file git-untracked-full-file git-untracked-loading-file");
|
|
5385
|
+
details.open = true;
|
|
5386
|
+
details.dataset.gitDiffFile = entry.path;
|
|
5387
|
+
const summary = make("summary", "git-diff-file-summary");
|
|
5388
|
+
summary.append(make("span", "git-diff-file-name", entry.path), make("span", "git-diff-file-stats", "loading content"));
|
|
5389
|
+
details.append(summary, make("pre", "git-diff-raw", "Loading complete untracked file content…"));
|
|
5390
|
+
return details;
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5393
|
+
function renderGitUntrackedFile(entry) {
|
|
5394
|
+
if (entry.contentMissing) return renderGitUntrackedLoadingFile(entry);
|
|
5395
|
+
if (entry.error || entry.binary) return renderGitUntrackedRawFile(entry);
|
|
5396
|
+
return renderGitDiffFile(gitUntrackedEntryToDiffFile(entry));
|
|
5397
|
+
}
|
|
5398
|
+
|
|
5399
|
+
function replaceGitUntrackedEntry(entry, tabId = gitChangesState.tabId) {
|
|
5400
|
+
const data = gitChangesState.data;
|
|
5401
|
+
if (!data || tabId !== gitChangesState.tabId) return;
|
|
5402
|
+
const entries = gitUntrackedEntries(data.untracked);
|
|
5403
|
+
const nextEntries = entries.map((item) => item.path === entry.path ? normalizeGitUntrackedEntry(entry) : item);
|
|
5404
|
+
gitChangesState = { ...gitChangesState, data: { ...data, untracked: nextEntries } };
|
|
5405
|
+
renderGitChangesDialog();
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5408
|
+
async function loadMissingGitUntrackedContent(entry, tabId = gitChangesState.tabId) {
|
|
5409
|
+
const key = `${tabId || ""}\u0000${entry.path}`;
|
|
5410
|
+
if (!entry.contentMissing || gitChangesUntrackedContentRequests.has(key)) return;
|
|
5411
|
+
gitChangesUntrackedContentRequests.add(key);
|
|
5412
|
+
try {
|
|
5413
|
+
const response = await api(`/api/git-changes/untracked-file?path=${encodeURIComponent(entry.path)}`, { tabId });
|
|
5414
|
+
if (!response.ok) throw new Error(response.error || "Failed to load untracked file content");
|
|
5415
|
+
replaceGitUntrackedEntry(response.data, tabId);
|
|
5416
|
+
} catch (error) {
|
|
5417
|
+
replaceGitUntrackedEntry({ ...entry, contentMissing: false, error: error.message || String(error) }, tabId);
|
|
5418
|
+
} finally {
|
|
5419
|
+
gitChangesUntrackedContentRequests.delete(key);
|
|
5420
|
+
}
|
|
5421
|
+
}
|
|
5422
|
+
|
|
5423
|
+
function renderGitUntrackedSection(untracked) {
|
|
5424
|
+
const entries = gitUntrackedEntries(untracked);
|
|
5425
|
+
const wrapper = make("section", "git-diff-section git-diff-section-untracked");
|
|
5426
|
+
const header = make("div", "git-diff-section-heading");
|
|
5427
|
+
header.append(
|
|
5428
|
+
make("div", "git-diff-section-title", "Untracked"),
|
|
5429
|
+
make("div", "git-diff-section-meta", `${entries.length} file${entries.length === 1 ? "" : "s"} · complete file contents`),
|
|
5430
|
+
);
|
|
5431
|
+
wrapper.append(header, ...entries.map(renderGitUntrackedFile));
|
|
5432
|
+
for (const entry of entries) {
|
|
5433
|
+
if (entry.contentMissing) queueMicrotask(() => loadMissingGitUntrackedContent(entry));
|
|
5434
|
+
}
|
|
5435
|
+
return wrapper;
|
|
5436
|
+
}
|
|
5437
|
+
|
|
5438
|
+
function renderGitCurrentFileHeader() {
|
|
5439
|
+
const header = make("div", "git-current-file-header");
|
|
5440
|
+
header.append(make("span", "git-current-file-label", "Current file"), make("span", "git-current-file-name", "—"));
|
|
5441
|
+
return header;
|
|
5442
|
+
}
|
|
5443
|
+
|
|
5444
|
+
function updateGitChangesCurrentFileHeader() {
|
|
5445
|
+
const body = elements.gitChangesBody;
|
|
5446
|
+
const header = body?.querySelector(".git-current-file-header");
|
|
5447
|
+
const name = header?.querySelector(".git-current-file-name");
|
|
5448
|
+
if (!body || !header || !name) return;
|
|
5449
|
+
const files = Array.from(body.querySelectorAll(".git-diff-file[data-git-diff-file]"));
|
|
5450
|
+
if (!files.length) {
|
|
5451
|
+
name.textContent = "—";
|
|
5452
|
+
return;
|
|
5453
|
+
}
|
|
5454
|
+
const bodyRect = body.getBoundingClientRect();
|
|
5455
|
+
const headerRect = header.getBoundingClientRect();
|
|
5456
|
+
const markerY = Math.min(bodyRect.bottom - 1, Math.max(bodyRect.top, headerRect.bottom + 8));
|
|
5457
|
+
let current = files[0];
|
|
5458
|
+
for (const file of files) {
|
|
5459
|
+
const rect = file.getBoundingClientRect();
|
|
5460
|
+
if (rect.top <= markerY && rect.bottom > markerY) {
|
|
5461
|
+
current = file;
|
|
5462
|
+
break;
|
|
5463
|
+
}
|
|
5464
|
+
if (rect.top <= markerY) current = file;
|
|
5465
|
+
else break;
|
|
5466
|
+
}
|
|
5467
|
+
name.textContent = current?.dataset.gitDiffFile || "—";
|
|
5468
|
+
}
|
|
5469
|
+
|
|
5470
|
+
function gitChangesGeneratedLabel(data) {
|
|
5471
|
+
const timestamp = Date.parse(data?.generatedAt || "");
|
|
5472
|
+
if (!Number.isFinite(timestamp)) return "";
|
|
5473
|
+
return `Updated ${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
|
|
5474
|
+
}
|
|
5475
|
+
|
|
5476
|
+
function renderGitChangesDialog() {
|
|
5477
|
+
if (!elements.gitChangesDialog || !elements.gitChangesBody) return;
|
|
5478
|
+
const { loading, error, data } = gitChangesState;
|
|
5479
|
+
if (elements.gitChangesTitle) elements.gitChangesTitle.textContent = "Uncommitted Changes";
|
|
5480
|
+
if (elements.gitChangesSubtitle) elements.gitChangesSubtitle.textContent = data?.root ? `${data.branch || "detached"} · ${data.root}` : "Current tab git diff";
|
|
5481
|
+
if (elements.gitChangesRefreshButton) {
|
|
5482
|
+
elements.gitChangesRefreshButton.disabled = loading;
|
|
5483
|
+
elements.gitChangesRefreshButton.textContent = loading ? "Refreshing…" : "Refresh";
|
|
5484
|
+
}
|
|
5485
|
+
if (elements.gitChangesStatus) {
|
|
5486
|
+
elements.gitChangesStatus.className = `git-changes-status ${error ? "error" : "muted"}`;
|
|
5487
|
+
elements.gitChangesStatus.textContent = error || (loading ? "Loading git diff…" : data ? gitChangesGeneratedLabel(data) : "");
|
|
5488
|
+
elements.gitChangesStatus.hidden = !elements.gitChangesStatus.textContent;
|
|
5489
|
+
}
|
|
5490
|
+
|
|
5491
|
+
const body = elements.gitChangesBody;
|
|
5492
|
+
body.replaceChildren();
|
|
5493
|
+
if (loading && !data) {
|
|
5494
|
+
body.append(make("div", "git-changes-empty", "Loading git diff…"));
|
|
5495
|
+
return;
|
|
5496
|
+
}
|
|
5497
|
+
if (error) {
|
|
5498
|
+
body.append(make("div", "git-changes-empty error", error));
|
|
5499
|
+
return;
|
|
5500
|
+
}
|
|
5501
|
+
if (!data) {
|
|
5502
|
+
body.append(make("div", "git-changes-empty", "Open from the footer CHANGES chip to load the current git diff."));
|
|
5503
|
+
return;
|
|
5504
|
+
}
|
|
5505
|
+
|
|
5506
|
+
body.append(renderGitChangesOverview(data));
|
|
5507
|
+
const parsedSections = (Array.isArray(data.sections) ? data.sections : [])
|
|
5508
|
+
.map((section) => ({ section, files: parseGitUnifiedDiff(section.diff || "") }))
|
|
5509
|
+
.filter((entry) => entry.files.length > 0);
|
|
5510
|
+
const untracked = gitUntrackedEntries(data.untracked);
|
|
5511
|
+
const hasVisibleFiles = parsedSections.length > 0 || untracked.length > 0;
|
|
5512
|
+
if (hasVisibleFiles) body.append(renderGitCurrentFileHeader());
|
|
5513
|
+
for (const entry of parsedSections) body.append(renderGitDiffSection(entry.section, entry.files));
|
|
5514
|
+
if (untracked.length) body.append(renderGitUntrackedSection(untracked));
|
|
5515
|
+
if (!hasVisibleFiles) body.append(make("div", "git-changes-empty success", "Working tree is clean. No staged or unstaged diff."));
|
|
5516
|
+
if (hasVisibleFiles) requestAnimationFrame(updateGitChangesCurrentFileHeader);
|
|
5517
|
+
}
|
|
5518
|
+
|
|
5519
|
+
async function loadGitChangesDialog(tabContext = activeTabContext()) {
|
|
5520
|
+
const requestSerial = ++gitChangesRequestSerial;
|
|
5521
|
+
gitChangesUntrackedContentRequests.clear();
|
|
5522
|
+
gitChangesState = { ...gitChangesState, loading: true, error: "", tabId: tabContext.tabId || activeTabId };
|
|
5523
|
+
renderGitChangesDialog();
|
|
5524
|
+
try {
|
|
5525
|
+
const response = await api("/api/git-changes", { tabId: tabContext.tabId });
|
|
5526
|
+
if (requestSerial !== gitChangesRequestSerial) return;
|
|
5527
|
+
if (!response.ok) throw new Error(response.error || "Failed to load git changes");
|
|
5528
|
+
gitChangesState = { loading: false, error: "", data: response.data || null, tabId: tabContext.tabId || activeTabId };
|
|
5529
|
+
} catch (error) {
|
|
5530
|
+
if (requestSerial !== gitChangesRequestSerial) return;
|
|
5531
|
+
gitChangesState = { ...gitChangesState, loading: false, error: error.message || String(error) };
|
|
5532
|
+
}
|
|
5533
|
+
renderGitChangesDialog();
|
|
5534
|
+
}
|
|
5535
|
+
|
|
5536
|
+
function openGitChangesDialog() {
|
|
5537
|
+
if (!elements.gitChangesDialog) return;
|
|
5538
|
+
hideFooterTooltip();
|
|
5539
|
+
const tabContext = activeTabContext();
|
|
5540
|
+
const tabId = tabContext.tabId || activeTabId;
|
|
5541
|
+
gitChangesState = { loading: true, error: "", data: gitChangesState.tabId === tabId ? gitChangesState.data : null, tabId };
|
|
5542
|
+
renderGitChangesDialog();
|
|
5543
|
+
if (!elements.gitChangesDialog.open) elements.gitChangesDialog.showModal();
|
|
5544
|
+
loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
|
|
5545
|
+
}
|
|
5546
|
+
|
|
5547
|
+
function refreshGitChangesDialog() {
|
|
5548
|
+
const tabContext = { tabId: gitChangesState.tabId || activeTabId };
|
|
5549
|
+
loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
|
|
5550
|
+
}
|
|
5551
|
+
|
|
5552
|
+
function closeGitChangesDialog() {
|
|
5553
|
+
gitChangesRequestSerial += 1;
|
|
5554
|
+
gitChangesUntrackedContentRequests.clear();
|
|
5555
|
+
gitChangesState = { ...gitChangesState, loading: false };
|
|
5556
|
+
if (elements.gitChangesDialog?.open) elements.gitChangesDialog.close();
|
|
5557
|
+
}
|
|
5558
|
+
|
|
4912
5559
|
function gitFooterFallbackMessage() {
|
|
4913
5560
|
if (isOptionalFeatureDisabled("gitFooterStatus")) return "";
|
|
4914
5561
|
const tabContext = activeTabContext();
|
|
@@ -4940,13 +5587,18 @@ function renderMinimalFooter() {
|
|
|
4940
5587
|
}));
|
|
4941
5588
|
if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
|
|
4942
5589
|
if (footerThinkingPickerOpen) elements.statusBar.append(renderFooterThinkingPicker());
|
|
5590
|
+
if (footerBranchPickerOpen) elements.statusBar.append(renderFooterBranchPicker());
|
|
4943
5591
|
setMobileFooterExpanded(false);
|
|
4944
5592
|
updateFooterModelPickerPosition();
|
|
4945
5593
|
}
|
|
4946
5594
|
|
|
4947
5595
|
function setFooterModelPickerOpen(open) {
|
|
4948
5596
|
footerModelPickerOpen = !!open;
|
|
4949
|
-
if (footerModelPickerOpen)
|
|
5597
|
+
if (footerModelPickerOpen) {
|
|
5598
|
+
footerThinkingPickerOpen = false;
|
|
5599
|
+
footerBranchPickerOpen = false;
|
|
5600
|
+
footerBranchPickerRequestSerial += 1;
|
|
5601
|
+
}
|
|
4950
5602
|
if (footerModelPickerOpen && isMobileView()) {
|
|
4951
5603
|
mobileFooterExpanded = false;
|
|
4952
5604
|
document.body.classList.remove("footer-details-expanded");
|
|
@@ -4960,7 +5612,11 @@ function setFooterModelPickerOpen(open) {
|
|
|
4960
5612
|
|
|
4961
5613
|
function setFooterThinkingPickerOpen(open) {
|
|
4962
5614
|
footerThinkingPickerOpen = !!open;
|
|
4963
|
-
if (footerThinkingPickerOpen)
|
|
5615
|
+
if (footerThinkingPickerOpen) {
|
|
5616
|
+
footerModelPickerOpen = false;
|
|
5617
|
+
footerBranchPickerOpen = false;
|
|
5618
|
+
footerBranchPickerRequestSerial += 1;
|
|
5619
|
+
}
|
|
4964
5620
|
if (footerThinkingPickerOpen && isMobileView()) {
|
|
4965
5621
|
mobileFooterExpanded = false;
|
|
4966
5622
|
document.body.classList.remove("footer-details-expanded");
|
|
@@ -4972,6 +5628,239 @@ function setFooterThinkingPickerOpen(open) {
|
|
|
4972
5628
|
updateFooterModelPickerPosition();
|
|
4973
5629
|
}
|
|
4974
5630
|
|
|
5631
|
+
function normalizeFooterGitBranches(data = {}) {
|
|
5632
|
+
const current = cleanStatusText(data.current || "");
|
|
5633
|
+
const seen = new Set();
|
|
5634
|
+
const branches = [];
|
|
5635
|
+
for (const item of Array.isArray(data.branches) ? data.branches : []) {
|
|
5636
|
+
const name = cleanStatusText(typeof item === "string" ? item : item?.name);
|
|
5637
|
+
if (!name || seen.has(name)) continue;
|
|
5638
|
+
seen.add(name);
|
|
5639
|
+
branches.push({ name, current: Boolean(item?.current) || (!!current && name === current) });
|
|
5640
|
+
}
|
|
5641
|
+
return {
|
|
5642
|
+
root: cleanFooterPayloadText(data.root, "", 4000),
|
|
5643
|
+
current,
|
|
5644
|
+
branches,
|
|
5645
|
+
};
|
|
5646
|
+
}
|
|
5647
|
+
|
|
5648
|
+
function applyOptimisticGitFooterBranch(branch, tabContext = activeTabContext()) {
|
|
5649
|
+
const nextBranch = cleanStatusText(branch);
|
|
5650
|
+
if (!nextBranch) return;
|
|
5651
|
+
const raw = statusEntries.get(GIT_FOOTER_WEBUI_STATUS_KEY) || readCachedGitFooterWebuiPayloadRaw();
|
|
5652
|
+
const payload = parseGitFooterWebuiPayloadRaw(raw);
|
|
5653
|
+
if (!payload) return;
|
|
5654
|
+
const nextPayload = {
|
|
5655
|
+
type: GIT_FOOTER_WEBUI_PAYLOAD_TYPE,
|
|
5656
|
+
version: GIT_FOOTER_WEBUI_PAYLOAD_VERSION,
|
|
5657
|
+
generatedAt: Date.now(),
|
|
5658
|
+
main: payload.main,
|
|
5659
|
+
meta: payload.meta.map((chip) => chip.key === "git" ? { ...chip, value: nextBranch, title: `git branch: ${nextBranch}` } : chip),
|
|
5660
|
+
};
|
|
5661
|
+
const nextRaw = JSON.stringify(nextPayload);
|
|
5662
|
+
statusEntries.set(GIT_FOOTER_WEBUI_STATUS_KEY, nextRaw);
|
|
5663
|
+
cacheGitFooterWebuiPayload(nextRaw, tabContext.tabId);
|
|
5664
|
+
}
|
|
5665
|
+
|
|
5666
|
+
async function loadFooterBranchPicker(tabContext = activeTabContext()) {
|
|
5667
|
+
const requestSerial = ++footerBranchPickerRequestSerial;
|
|
5668
|
+
const tabId = tabContext.tabId || activeTabId;
|
|
5669
|
+
footerBranchPickerState = {
|
|
5670
|
+
loading: true,
|
|
5671
|
+
error: "",
|
|
5672
|
+
branches: footerBranchPickerState.tabId === tabId ? footerBranchPickerState.branches : [],
|
|
5673
|
+
current: footerBranchPickerState.tabId === tabId ? footerBranchPickerState.current : "",
|
|
5674
|
+
root: footerBranchPickerState.tabId === tabId ? footerBranchPickerState.root : "",
|
|
5675
|
+
switching: "",
|
|
5676
|
+
tabId,
|
|
5677
|
+
};
|
|
5678
|
+
if (isCurrentTabContext(tabContext)) {
|
|
5679
|
+
renderFooter();
|
|
5680
|
+
updateFooterModelPickerPosition();
|
|
5681
|
+
}
|
|
5682
|
+
try {
|
|
5683
|
+
const response = await api("/api/git-branches", { tabId });
|
|
5684
|
+
if (requestSerial !== footerBranchPickerRequestSerial || !footerBranchPickerOpen || !isCurrentTabContext(tabContext)) return;
|
|
5685
|
+
if (!response.ok) throw new Error(response.error || "Failed to load git branches");
|
|
5686
|
+
footerBranchPickerState = { loading: false, error: "", switching: "", tabId, ...normalizeFooterGitBranches(response.data || {}) };
|
|
5687
|
+
} catch (error) {
|
|
5688
|
+
if (requestSerial !== footerBranchPickerRequestSerial || !footerBranchPickerOpen || !isCurrentTabContext(tabContext)) return;
|
|
5689
|
+
footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", error: error.message || String(error) };
|
|
5690
|
+
}
|
|
5691
|
+
if (isCurrentTabContext(tabContext)) {
|
|
5692
|
+
renderFooter();
|
|
5693
|
+
updateFooterModelPickerPosition();
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
|
|
5697
|
+
function setFooterBranchPickerOpen(open) {
|
|
5698
|
+
footerBranchPickerOpen = !!open;
|
|
5699
|
+
if (footerBranchPickerOpen) {
|
|
5700
|
+
footerModelPickerOpen = false;
|
|
5701
|
+
footerThinkingPickerOpen = false;
|
|
5702
|
+
if (isMobileView()) {
|
|
5703
|
+
mobileFooterExpanded = false;
|
|
5704
|
+
document.body.classList.remove("footer-details-expanded");
|
|
5705
|
+
setComposerActionsOpen(false);
|
|
5706
|
+
setMobileTabsExpanded(false);
|
|
5707
|
+
}
|
|
5708
|
+
loadFooterBranchPicker(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
|
|
5709
|
+
} else {
|
|
5710
|
+
footerBranchPickerRequestSerial += 1;
|
|
5711
|
+
}
|
|
5712
|
+
document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
|
|
5713
|
+
renderFooter();
|
|
5714
|
+
updateFooterModelPickerPosition();
|
|
5715
|
+
}
|
|
5716
|
+
|
|
5717
|
+
function pathLooksInside(parentPath, childPath) {
|
|
5718
|
+
const normalizePath = (value) => String(value || "").replace(/\\+/g, "/").replace(/\/+$/, "");
|
|
5719
|
+
const parent = normalizePath(parentPath);
|
|
5720
|
+
const child = normalizePath(childPath);
|
|
5721
|
+
return !!parent && !!child && (child === parent || child.startsWith(`${parent}/`));
|
|
5722
|
+
}
|
|
5723
|
+
|
|
5724
|
+
function footerBranchActiveAgentTabs(tabContext = activeTabContext()) {
|
|
5725
|
+
const active = activeTab();
|
|
5726
|
+
const activeCwd = latestWorkspace?.cwd || active?.cwd || "";
|
|
5727
|
+
const root = footerBranchPickerState.root || "";
|
|
5728
|
+
return tabs.filter((tab) => {
|
|
5729
|
+
const sameWorktree = root ? pathLooksInside(root, tab.cwd) : !!activeCwd && tab.cwd === activeCwd;
|
|
5730
|
+
if (!sameWorktree) return false;
|
|
5731
|
+
if (tab.id === tabContext.tabId) return currentState?.isStreaming || currentState?.isCompacting || tabHasActiveAgent(tab);
|
|
5732
|
+
return tabHasActiveAgent(tab);
|
|
5733
|
+
});
|
|
5734
|
+
}
|
|
5735
|
+
|
|
5736
|
+
function footerBranchAgentWarningLines(tabContext = activeTabContext()) {
|
|
5737
|
+
const busyTabs = footerBranchActiveAgentTabs(tabContext);
|
|
5738
|
+
if (!busyTabs.length) return [];
|
|
5739
|
+
const list = busyTabs.slice(0, 4).map((tab) => `- ${tab.title || tab.id}`).join("\n");
|
|
5740
|
+
const extra = busyTabs.length > 4 ? `\n- … +${busyTabs.length - 4} more` : "";
|
|
5741
|
+
return [
|
|
5742
|
+
"",
|
|
5743
|
+
`WARNING: ${busyTabs.length === 1 ? "An agent is" : "Agents are"} still running or waiting for input in this Git working tree:`,
|
|
5744
|
+
`${list}${extra}`,
|
|
5745
|
+
"Switching branches can change files underneath the running agent.",
|
|
5746
|
+
];
|
|
5747
|
+
}
|
|
5748
|
+
|
|
5749
|
+
function confirmFooterGitBranchAction(branch, { create = false, requireConfirm = false, tabContext = activeTabContext() } = {}) {
|
|
5750
|
+
const branchName = cleanStatusText(branch);
|
|
5751
|
+
const warningLines = footerBranchAgentWarningLines(tabContext);
|
|
5752
|
+
if (!requireConfirm && warningLines.length === 0) return true;
|
|
5753
|
+
const action = create ? "Create and switch to new git branch" : "Switch git branch";
|
|
5754
|
+
const message = [
|
|
5755
|
+
`${action}: ${branchName}?`,
|
|
5756
|
+
"",
|
|
5757
|
+
`Repository: ${footerBranchPickerState.root || currentGitFooterCacheCwd(tabContext.tabId) || "current tab"}`,
|
|
5758
|
+
...warningLines,
|
|
5759
|
+
"",
|
|
5760
|
+
"Continue?",
|
|
5761
|
+
].join("\n");
|
|
5762
|
+
return window.confirm(message);
|
|
5763
|
+
}
|
|
5764
|
+
|
|
5765
|
+
function promptFooterGitBranchName() {
|
|
5766
|
+
const value = window.prompt("New git branch name:", "");
|
|
5767
|
+
if (value === null) return "";
|
|
5768
|
+
return cleanStatusText(value);
|
|
5769
|
+
}
|
|
5770
|
+
|
|
5771
|
+
async function createFooterGitBranch() {
|
|
5772
|
+
const branchName = promptFooterGitBranchName();
|
|
5773
|
+
if (!branchName) return;
|
|
5774
|
+
const tabContext = activeTabContext();
|
|
5775
|
+
if (!confirmFooterGitBranchAction(branchName, { create: true, requireConfirm: true, tabContext })) return;
|
|
5776
|
+
await applyFooterGitBranch(branchName, { create: true, tabContext, skipConfirm: true });
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5779
|
+
async function applyFooterGitBranch(branch, { create = false, tabContext = activeTabContext(), skipConfirm = false } = {}) {
|
|
5780
|
+
const branchName = cleanStatusText(branch);
|
|
5781
|
+
if (!branchName) return;
|
|
5782
|
+
const tabId = tabContext.tabId || activeTabId;
|
|
5783
|
+
if (!skipConfirm && !confirmFooterGitBranchAction(branchName, { create, tabContext })) return;
|
|
5784
|
+
try {
|
|
5785
|
+
footerBranchPickerState = { ...footerBranchPickerState, loading: true, error: "", switching: branchName, tabId };
|
|
5786
|
+
renderFooter();
|
|
5787
|
+
const response = await api("/api/git-branch", { method: "POST", body: { branch: branchName, create }, tabId });
|
|
5788
|
+
if (!isCurrentTabContext(tabContext)) return;
|
|
5789
|
+
if (!response.ok) throw new Error(response.error || `Failed to ${create ? "create and switch to" : "switch to"} ${branchName}`);
|
|
5790
|
+
const switchedBranch = cleanStatusText(response.data?.branch || branchName);
|
|
5791
|
+
footerBranchPickerOpen = false;
|
|
5792
|
+
footerBranchPickerRequestSerial += 1;
|
|
5793
|
+
footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", current: switchedBranch };
|
|
5794
|
+
applyOptimisticGitFooterBranch(switchedBranch, tabContext);
|
|
5795
|
+
addEvent(response.data?.created ? `Created and switched to git branch ${switchedBranch}.` : response.data?.switched === false ? `Already on git branch ${switchedBranch}.` : `Switched git branch to ${switchedBranch}.`, "info");
|
|
5796
|
+
requestGitFooterWebuiPayload(tabContext, { force: true });
|
|
5797
|
+
} catch (error) {
|
|
5798
|
+
if (isCurrentTabContext(tabContext)) {
|
|
5799
|
+
footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", error: error.message || String(error) };
|
|
5800
|
+
addEvent(error.message || String(error), "error");
|
|
5801
|
+
}
|
|
5802
|
+
} finally {
|
|
5803
|
+
if (isCurrentTabContext(tabContext)) {
|
|
5804
|
+
document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
|
|
5805
|
+
renderFooter();
|
|
5806
|
+
updateFooterModelPickerPosition();
|
|
5807
|
+
}
|
|
5808
|
+
}
|
|
5809
|
+
}
|
|
5810
|
+
|
|
5811
|
+
function renderFooterBranchPicker() {
|
|
5812
|
+
const picker = make("div", "footer-model-picker footer-branch-picker");
|
|
5813
|
+
picker.setAttribute("role", "listbox");
|
|
5814
|
+
picker.setAttribute("aria-label", "Git branches");
|
|
5815
|
+
const state = footerBranchPickerState;
|
|
5816
|
+
const current = state.current || "detached";
|
|
5817
|
+
picker.append(make("div", "footer-model-picker-title", "Git branches"));
|
|
5818
|
+
picker.append(make("div", "footer-model-picker-source", `${state.loading ? "Refreshing" : "Current"}: ${state.switching || current}${state.root ? ` · ${state.root}` : ""}`));
|
|
5819
|
+
|
|
5820
|
+
if (state.error) {
|
|
5821
|
+
const error = make("div", "footer-model-picker-empty error");
|
|
5822
|
+
error.append(make("strong", undefined, "Cannot load branches."), make("span", undefined, ` ${state.error}`));
|
|
5823
|
+
picker.append(error);
|
|
5824
|
+
return picker;
|
|
5825
|
+
}
|
|
5826
|
+
if (state.loading && state.branches.length === 0) {
|
|
5827
|
+
picker.append(make("div", "footer-model-picker-empty muted", "Loading local branches…"));
|
|
5828
|
+
return picker;
|
|
5829
|
+
}
|
|
5830
|
+
|
|
5831
|
+
const hasOtherBranches = state.branches.some((branch) => !branch.current && branch.name !== state.current);
|
|
5832
|
+
if (!state.loading && !hasOtherBranches) {
|
|
5833
|
+
const empty = make("div", "footer-model-picker-empty muted");
|
|
5834
|
+
empty.append(make("strong", undefined, "No other local branches available."), make("span", undefined, " Create a branch from the current HEAD to continue."));
|
|
5835
|
+
const createButton = make("button", "footer-model-option footer-branch-create-option");
|
|
5836
|
+
createButton.type = "button";
|
|
5837
|
+
createButton.append(
|
|
5838
|
+
make("span", "footer-model-option-main", "Create new branch"),
|
|
5839
|
+
make("span", "footer-model-option-name", "prompts for a name, confirms, then runs git switch -c"),
|
|
5840
|
+
);
|
|
5841
|
+
createButton.addEventListener("click", () => createFooterGitBranch().catch((error) => addEvent(error.message || String(error), "error")));
|
|
5842
|
+
picker.append(empty, createButton);
|
|
5843
|
+
}
|
|
5844
|
+
|
|
5845
|
+
for (const branch of state.branches) {
|
|
5846
|
+
const selected = branch.current || (!!state.current && branch.name === state.current);
|
|
5847
|
+
const disabled = selected || state.loading || !!state.switching;
|
|
5848
|
+
const button = make("button", `footer-model-option footer-branch-option${selected ? " active" : ""}`);
|
|
5849
|
+
button.type = "button";
|
|
5850
|
+
button.disabled = disabled;
|
|
5851
|
+
button.setAttribute("role", "option");
|
|
5852
|
+
button.setAttribute("aria-selected", selected ? "true" : "false");
|
|
5853
|
+
button.title = selected ? `Current branch: ${branch.name}` : `git switch ${branch.name}`;
|
|
5854
|
+
button.append(
|
|
5855
|
+
make("span", "footer-model-option-main", branch.name),
|
|
5856
|
+
make("span", "footer-model-option-name", selected ? "current branch" : state.switching === branch.name ? "switching…" : "switch to this branch"),
|
|
5857
|
+
);
|
|
5858
|
+
if (!disabled) button.addEventListener("click", () => applyFooterGitBranch(branch.name));
|
|
5859
|
+
picker.append(button);
|
|
5860
|
+
}
|
|
5861
|
+
return picker;
|
|
5862
|
+
}
|
|
5863
|
+
|
|
4975
5864
|
async function applyFooterModel(model) {
|
|
4976
5865
|
if (!model?.provider || !model?.id) return;
|
|
4977
5866
|
const tabContext = activeTabContext();
|
|
@@ -12084,9 +12973,11 @@ function updateServerActionButton() {
|
|
|
12084
12973
|
const button = elements.runServerActionButton;
|
|
12085
12974
|
if (!button) return;
|
|
12086
12975
|
button.disabled = !action;
|
|
12087
|
-
button.textContent = action === "restart" ? "Restart" : action === "stop" ? "Stop" : "Run";
|
|
12976
|
+
button.textContent = action === "restart" ? "Restart" : action === "update" ? "Update" : action === "stop" ? "Stop" : "Run";
|
|
12088
12977
|
button.classList.toggle("danger", action === "stop");
|
|
12089
|
-
if (action
|
|
12978
|
+
if (action === "restart") setServerActionStatus("Ready to restart the Web UI server.", "info");
|
|
12979
|
+
else if (action === "update") setServerActionStatus("Ready to run pi update, then restart the Web UI server.", "info");
|
|
12980
|
+
else if (action === "stop") setServerActionStatus("Ready to stop the Web UI server.", "info");
|
|
12090
12981
|
else setServerActionStatus();
|
|
12091
12982
|
}
|
|
12092
12983
|
|
|
@@ -12192,6 +13083,7 @@ async function stopServer() {
|
|
|
12192
13083
|
async function runSelectedServerAction() {
|
|
12193
13084
|
const action = elements.serverActionSelect?.value || "";
|
|
12194
13085
|
if (action === "restart") await restartServer();
|
|
13086
|
+
else if (action === "update") await runPiUpdateAndRestart();
|
|
12195
13087
|
else if (action === "stop") await stopServer();
|
|
12196
13088
|
}
|
|
12197
13089
|
|
|
@@ -13405,6 +14297,8 @@ if (elements.backgroundClearButton) {
|
|
|
13405
14297
|
elements.openNetworkButton.addEventListener("click", openToNetwork);
|
|
13406
14298
|
elements.serverActionSelect.addEventListener("change", updateServerActionButton);
|
|
13407
14299
|
elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
|
|
14300
|
+
elements.updateNotificationUpdateButton?.addEventListener("click", () => runPiUpdateAndRestart().catch((error) => addEvent(error.message || String(error), "error")));
|
|
14301
|
+
elements.updateNotificationDismissButton?.addEventListener("click", () => hideUpdateNotification({ remember: true }));
|
|
13408
14302
|
updateServerActionButton();
|
|
13409
14303
|
elements.agentDoneNotificationsToggle.addEventListener("change", () => {
|
|
13410
14304
|
setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
|
|
@@ -13478,6 +14372,7 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
13478
14372
|
if (isFooterPickerOpen() && !elements.statusBar.contains(event.target)) {
|
|
13479
14373
|
setFooterModelPickerOpen(false);
|
|
13480
14374
|
setFooterThinkingPickerOpen(false);
|
|
14375
|
+
setFooterBranchPickerOpen(false);
|
|
13481
14376
|
}
|
|
13482
14377
|
}, { passive: true });
|
|
13483
14378
|
document.addEventListener("pointermove", (event) => {
|
|
@@ -13495,7 +14390,7 @@ function isTextEntryTarget(target) {
|
|
|
13495
14390
|
|
|
13496
14391
|
function shouldHandleNativeAppShortcut(event) {
|
|
13497
14392
|
if (event.defaultPrevented) return false;
|
|
13498
|
-
if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
|
|
14393
|
+
if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
|
|
13499
14394
|
return event.target === elements.promptInput || !isTextEntryTarget(event.target);
|
|
13500
14395
|
}
|
|
13501
14396
|
|
|
@@ -13554,7 +14449,7 @@ window.addEventListener("focus", () => scheduleForegroundReconcile("window focus
|
|
|
13554
14449
|
window.addEventListener("online", () => scheduleForegroundReconcile("network online", 0));
|
|
13555
14450
|
window.addEventListener("keydown", (event) => {
|
|
13556
14451
|
if (event.key !== "Escape") return;
|
|
13557
|
-
if (elements.dialog?.open || elements.pathPickerDialog?.open) return;
|
|
14452
|
+
if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open) return;
|
|
13558
14453
|
if (publishMenuOpen) {
|
|
13559
14454
|
setPublishMenuOpen(false);
|
|
13560
14455
|
return;
|
|
@@ -13592,6 +14487,7 @@ window.addEventListener("keydown", (event) => {
|
|
|
13592
14487
|
if (isFooterPickerOpen()) {
|
|
13593
14488
|
setFooterModelPickerOpen(false);
|
|
13594
14489
|
setFooterThinkingPickerOpen(false);
|
|
14490
|
+
setFooterBranchPickerOpen(false);
|
|
13595
14491
|
return;
|
|
13596
14492
|
}
|
|
13597
14493
|
if (!elements.commandSuggest.hidden) {
|
|
@@ -13618,6 +14514,18 @@ window.addEventListener("keydown", (event) => {
|
|
|
13618
14514
|
}
|
|
13619
14515
|
});
|
|
13620
14516
|
|
|
14517
|
+
elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);
|
|
14518
|
+
elements.gitChangesCloseButton?.addEventListener("click", closeGitChangesDialog);
|
|
14519
|
+
elements.gitChangesBody?.addEventListener("scroll", updateGitChangesCurrentFileHeader, { passive: true });
|
|
14520
|
+
elements.gitChangesDialog?.addEventListener("cancel", (event) => {
|
|
14521
|
+
event.preventDefault();
|
|
14522
|
+
closeGitChangesDialog();
|
|
14523
|
+
});
|
|
14524
|
+
elements.gitChangesDialog?.addEventListener("close", () => {
|
|
14525
|
+
gitChangesRequestSerial += 1;
|
|
14526
|
+
gitChangesState = { ...gitChangesState, loading: false };
|
|
14527
|
+
});
|
|
14528
|
+
|
|
13621
14529
|
elements.refreshCodexUsageButton?.addEventListener("click", () => {
|
|
13622
14530
|
refreshCodexUsage({ forceAuthRefresh: true }).finally(() => scheduleRefreshCodexUsage());
|
|
13623
14531
|
});
|
|
@@ -13752,6 +14660,7 @@ restoreSidePanelSectionState();
|
|
|
13752
14660
|
bindSidePanelSectionToggles();
|
|
13753
14661
|
restoreSidePanelState();
|
|
13754
14662
|
initializeCodexUsage();
|
|
14663
|
+
initializeUpdateNotifications();
|
|
13755
14664
|
bindMobileViewChanges();
|
|
13756
14665
|
bindSidePanelOverlayViewChanges();
|
|
13757
14666
|
registerPwaServiceWorker();
|