diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 89d5ec5..7a00b79 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -144,7 +144,7 @@ dc9b5971c9fefe8c374aa31916f5513601ce86003fd48b1d0e51330a909ae3a5 3442 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs e64f7257d478955c675a133b3735b6afe138a69d2ad090898061e56f557c43e5 9926 src/main/production-acceptance-harness.cjs -e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 src/main/repository-monitor.cjs +fc7a5a2a42579a311f60a96bec4776942bf3f63428928820029b0dcf4874d1aa 2745 src/main/repository-monitor.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs @@ -164,13 +164,13 @@ d0bf607dd1de9d55f2947d0adf0997cd3ca5c269d10a5362cc1d8bc4d1a2a8ae 6706 4227a05a20580a31127d2c929640defc3d36e8e3e89d6be830aab1940da81082 12267 src/renderer/actions/recovery.js cdfaacdcd5ae04b0e5c79fefa21f5e09d5c810bcea504c5b6e1d6b744182ff84 15567 src/renderer/actions/setup-and-settings.js 5d8110918b2957889047e38eb4ab2953b2b4476394d9328bd40ae6e4246e65ef 18584 src/renderer/actions/shell.js -100077a82f14d5252753d017369ecef48ed6d00532166ae5a0b356ead549e02b 24087 src/renderer/app.js +edcfa0585af4b11ec3b7458969132bdf215830505859f1519f1635dc111ce48a 24333 src/renderer/app.js 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 1a577af2459715cf38d49d118da8c5f897a7ff1d3eb42ad41f121675eb871732 48389 src/renderer/dialogs.js -977af9585074f0a404830c5a93de6939b4897d2eb98ba8ae75c3cf0023dea673 5782 src/renderer/events.js +eef2f269ba4fbb76bf66ad328d481b461255d0acb753b30878dd4d4eaac57dc6 6924 src/renderer/events.js a84da5aecbb16ce7983dba1f6d6aab1bf47b2e9a87c2933fa1afb8123f7ef7d6 1497 src/renderer/index.html 06180d9656dd254edfb6949c397f8e313954fc560ddcb22b3a35fce3c3e35655 21350 src/renderer/mock-bridge.js 9fa522dad0088e2981c4ca5c392e93d6c92e04ff23fd1083d9040a3aa52d2b55 28101 src/renderer/mock-deployment-bridge.js @@ -214,7 +214,7 @@ caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 0cb884cf62c1cb02cf59a81662be055bcb5339d176de85e2a3eeb8e8573e11b3 6435 tests/production-acceptance.test.mjs 629ba26395c0b49cc5fdee6b0646d75369eb6338e1cc7b59509938f97eea08ec 9601 tests/renderer-workflow.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs -f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs +62e6d3df7051778124648c808caec634a6b2c439660dc556392ea10beec926d1 1870 tests/repository-monitor.test.mjs ebd3c0825bc9e2f1690cfd51e93a96bd33e939eb9c546aa373dd948b8cf71a69 7781 tests/repository-service.test.mjs d49c772e3c7ddaa12dc5a1d4fc4cb474a4d99ae06fa5dab5a6cf1c44acb9ed6f 3463 tests/security-validation.test.mjs bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 tests/semver.test.mjs diff --git a/src/main/repository-monitor.cjs b/src/main/repository-monitor.cjs index d102871..08fdc3e 100644 --- a/src/main/repository-monitor.cjs +++ b/src/main/repository-monitor.cjs @@ -40,27 +40,32 @@ class RepositoryMonitor { if (this.running || !this.paths.length) return; this.running = true; try { - for (const localPath of this.paths) { - if (this.paused.has(localPath)) continue; - try { - const status = await this.git.status(localPath); - const next = this.git.statusFingerprint(status); - const previous = this.fingerprints.get(localPath); - this.fingerprints.set(localPath, next); - if (previous && previous !== next) { - await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts }); - this.onChange?.({ localPath, status, reason: 'working-tree-changed' }); - } - } catch (error) { - const next = `error:${error.message}`; - const previous = this.fingerprints.get(localPath); - this.fingerprints.set(localPath, next); - if (previous && previous !== next) { - await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message }); - this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' }); + const queue = [...this.paths]; + const workers = Array.from({ length: Math.min(4, queue.length) }, async () => { + while (queue.length) { + const localPath = queue.shift(); + if (this.paused.has(localPath)) continue; + try { + const status = await this.git.status(localPath); + const next = this.git.statusFingerprint(status); + const previous = this.fingerprints.get(localPath); + this.fingerprints.set(localPath, next); + if (previous && previous !== next) { + await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts }); + this.onChange?.({ localPath, status, reason: 'working-tree-changed' }); + } + } catch (error) { + const next = `error:${error.message}`; + const previous = this.fingerprints.get(localPath); + this.fingerprints.set(localPath, next); + if (previous && previous !== next) { + await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message }); + this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' }); + } } } - } + }); + await Promise.all(workers); } finally { this.running = false; } diff --git a/src/renderer/app.js b/src/renderer/app.js index 3c86a75..f980c48 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -173,6 +173,7 @@ const ui = { setupValidation: null, activeDeployment: null, operationPollTimer: null, + inputRenderTimer: null, isMock: false, refreshError: null, autoRefreshPending: false, @@ -190,6 +191,14 @@ const ui = { auditEvents: [], }; +function scheduleInputRender(delay = 120) { + if (ui.inputRenderTimer) clearTimeout(ui.inputRenderTimer); + ui.inputRenderTimer = setTimeout(() => { + ui.inputRenderTimer = null; + render(); + }, delay); +} + function selectedRepository() { return ( ui.repositories.find( diff --git a/src/renderer/events.js b/src/renderer/events.js index e44bbda..a2b540a 100644 --- a/src/renderer/events.js +++ b/src/renderer/events.js @@ -17,20 +17,32 @@ app.addEventListener("click", async (event) => { app.addEventListener("input", (event) => { if (event.target.id === "global-search") { ui.search = event.target.value; - render(); - document.querySelector("#global-search")?.focus(); + scheduleInputRender(); } else if (event.target.id === "repo-filter") { ui.repoSearch = event.target.value; - render(); - document.querySelector("#repo-filter")?.focus(); + scheduleInputRender(); } else if (event.target.id === "commit-message") { ui.commitMessage = event.target.value; - const position = event.target.selectionStart; - render(); - const next = document.querySelector("#commit-message"); - if (next) { - next.focus(); - next.setSelectionRange(position, position); + const repository = selectedRepository(); + const hasSelection = Boolean(ui.selectedFiles.size || repository?.localStatus?.counts?.staged); + const ready = Boolean(hasSelection && ui.commitMessage.trim()); + const blocker = !hasSelection + ? "Select files or stage one or more hunks." + : ready + ? ui.selectedFiles.size + ? "Ready to commit. ForgeFlow stages the selected files automatically." + : "Ready to commit only the reviewed staged hunks." + : "Enter a commit message to enable commit and push."; + const readiness = document.querySelector(".commit-readiness"); + if (readiness) { + readiness.classList.toggle("ready", ready); + readiness.classList.toggle("blocked", !ready); + readiness.innerHTML = `${icon(ready ? "check" : "warning")}${escapeHtml(blocker)}`; + } + for (const button of document.querySelectorAll('[data-action="commit-push"], [data-action="commit-only"]')) { + button.disabled = !ready; + if (ready) button.removeAttribute("title"); + else button.title = blocker; } } else if (event.target.id === "setup-url") ui.setupDraft.baseUrl = event.target.value; @@ -38,7 +50,7 @@ app.addEventListener("input", (event) => { ui.setupDraft.token = event.target.value; else if (event.target.id === "palette-input") { ui.paletteQuery = event.target.value; - render(); + scheduleInputRender(60); } }); @@ -102,35 +114,32 @@ document.addEventListener("keydown", (event) => { } }); +let pointerAnimationFrame = null; +let pendingPointer = null; + document.addEventListener("pointermove", (event) => { - const illustration = event.target.closest?.("[data-project-illustration]"); - if (illustration) { - const bounds = illustration.getBoundingClientRect(); - illustration.style.setProperty( - "--tilt-x", - `${((event.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`, - ); - illustration.style.setProperty( - "--tilt-y", - `${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`, - ); - } - - const diffPanel = event.target.closest?.(".diff-panel"); - const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]"); - if (atmosphere) { - const bounds = diffPanel.getBoundingClientRect(); - atmosphere.style.setProperty( - "--diff-tilt-x", - `${((event.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`, - ); - atmosphere.style.setProperty( - "--diff-tilt-y", - `${((event.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`, - ); - } + pendingPointer = { target: event.target, clientX: event.clientX, clientY: event.clientY }; + if (pointerAnimationFrame) return; + pointerAnimationFrame = requestAnimationFrame(() => { + pointerAnimationFrame = null; + const current = pendingPointer; + pendingPointer = null; + if (!current) return; + const illustration = current.target.closest?.("[data-project-illustration]"); + if (illustration) { + const bounds = illustration.getBoundingClientRect(); + illustration.style.setProperty("--tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`); + illustration.style.setProperty("--tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`); + } + const diffPanel = current.target.closest?.(".diff-panel"); + const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]"); + if (atmosphere) { + const bounds = diffPanel.getBoundingClientRect(); + atmosphere.style.setProperty("--diff-tilt-x", `${((current.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`); + atmosphere.style.setProperty("--diff-tilt-y", `${((current.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`); + } + }); }); - document.addEventListener("pointerout", (event) => { const illustration = event.target.closest?.("[data-project-illustration]"); if (illustration && !illustration.contains(event.relatedTarget)) { diff --git a/tests/repository-monitor.test.mjs b/tests/repository-monitor.test.mjs index f6a4368..5367f18 100644 --- a/tests/repository-monitor.test.mjs +++ b/tests/repository-monitor.test.mjs @@ -28,3 +28,24 @@ test('repository monitor establishes a baseline and emits only on later changes' await monitor.tick(); assert.equal(changes.length, 2); }); +test('repository monitor checks multiple repositories concurrently with a bounded worker pool', async () => { + let active = 0; + let peak = 0; + const git = { + status: async (localPath) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 15)); + active -= 1; + return { localPath, revision: 1 }; + }, + statusFingerprint: (status) => String(status.revision) + }; + const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } }; + const monitor = new RepositoryMonitor({ store, git }); + monitor.setPaths(Array.from({ length: 10 }, (_, index) => `/repo-${index}`)); + await monitor.tick(); + assert.equal(peak, 4); + assert.equal(active, 0); + assert.equal(monitor.fingerprints.size, 10); +});