diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index cb2a7dfcb..3899d9be0 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-tabs": "^1.1.13", + "@xyflow/react": "^12.5.0", "chokidar": "^4.0.3", "clsx": "^2.1.1", "lucide-react": "^0.563.0", @@ -1794,6 +1795,49 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/emscripten": { "version": "1.41.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", @@ -1901,6 +1945,61 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.5.0.tgz", + "integrity": "sha512-8QwsoH04kAbvTrG5fpCbwyqStAgJW81u0f2H60UmYUGL4Ps9lLpwYh6cW1Z3MSs6YBU9bDRpQV8zY0LNQizUUQ==", + "dependencies": { + "@xyflow/system": "0.0.53", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.53", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.53.tgz", + "integrity": "sha512-QTWieiTtvNYyQAz1fxpzgtUGXNpnhfh6vvZa7dFWpWS2KOz6bEHODo/DTK3s07lDu0Bq0Db5lx/5M5mNjb9VDQ==", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -2398,6 +2497,11 @@ "node": ">=10" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -2617,6 +2721,102 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "dev": true, @@ -6393,6 +6593,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7689,6 +7897,49 @@ "@types/responselike": "^1.0.0" } }, + "@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" + }, + "@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "requires": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "@types/emscripten": { "version": "1.41.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", @@ -7779,6 +8030,40 @@ "react-refresh": "^0.18.0" } }, + "@xyflow/react": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.5.0.tgz", + "integrity": "sha512-8QwsoH04kAbvTrG5fpCbwyqStAgJW81u0f2H60UmYUGL4Ps9lLpwYh6cW1Z3MSs6YBU9bDRpQV8zY0LNQizUUQ==", + "requires": { + "@xyflow/system": "0.0.53", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "dependencies": { + "zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "requires": { + "use-sync-external-store": "^1.2.2" + } + } + } + }, + "@xyflow/system": { + "version": "0.0.53", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.53.tgz", + "integrity": "sha512-QTWieiTtvNYyQAz1fxpzgtUGXNpnhfh6vvZa7dFWpWS2KOz6bEHODo/DTK3s07lDu0Bq0Db5lx/5M5mNjb9VDQ==", + "requires": { + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -8091,6 +8376,11 @@ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true }, + "classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -8230,6 +8520,72 @@ "version": "3.2.3", "devOptional": true }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==" + }, + "d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + } + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==" + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "requires": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + } + }, + "d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + } + }, "debug": { "version": "4.4.3", "dev": true, @@ -10497,6 +10853,12 @@ "tslib": "^2.0.0" } }, + "use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d987ff86b..956ed23f6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "electron.cjs", "scripts": { - "dev": "concurrently -k -n renderer,main,electron \"vite --port 5173\" \"tsup --watch\" \"wait-on tcp:5173 && wait-on file:dist/main/main.cjs && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .\"", + "dev": "node ./scripts/ensure-electron.cjs && concurrently -k -n renderer,main,electron \"vite --port 5173\" \"tsup --watch\" \"wait-on tcp:5173 && wait-on file:dist/main/main.cjs && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .\"", "build": "tsup && vite build", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "echo \"(todo)\"", @@ -15,6 +15,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-tabs": "^1.1.13", + "@xyflow/react": "^12.5.0", "chokidar": "^4.0.3", "clsx": "^2.1.1", "lucide-react": "^0.563.0", diff --git a/apps/desktop/scripts/ensure-electron.cjs b/apps/desktop/scripts/ensure-electron.cjs new file mode 100644 index 000000000..b9ce033c5 --- /dev/null +++ b/apps/desktop/scripts/ensure-electron.cjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const childProcess = require("node:child_process"); + +const projectRoot = path.resolve(__dirname, ".."); +const electronDir = path.join(projectRoot, "node_modules", "electron"); +const installScript = path.join(electronDir, "install.js"); +const distDir = path.join(electronDir, "dist"); +const pathFile = path.join(electronDir, "path.txt"); +const cacheDir = path.join(projectRoot, ".cache", "electron"); + +function hasInstalledBinary() { + if (!fs.existsSync(distDir)) return false; + if (!fs.existsSync(pathFile)) return false; + const relative = fs.readFileSync(pathFile, "utf8").trim(); + if (!relative) return false; + return fs.existsSync(path.join(distDir, relative)); +} + +function main() { + if (!fs.existsSync(installScript)) { + console.error("[ensure-electron] Missing electron install script at node_modules/electron."); + console.error("[ensure-electron] Run `npm install` in apps/desktop first."); + process.exit(1); + } + + if (hasInstalledBinary()) { + return; + } + + fs.mkdirSync(cacheDir, { recursive: true }); + console.log("[ensure-electron] Electron binary missing; running install.js..."); + const result = childProcess.spawnSync(process.execPath, [installScript], { + cwd: projectRoot, + stdio: "inherit", + env: { + ...process.env, + electron_config_cache: process.env.electron_config_cache || cacheDir + } + }); + + if (result.status !== 0) { + console.error("[ensure-electron] Electron install failed."); + process.exit(result.status ?? 1); + } + + if (!hasInstalledBinary()) { + console.error("[ensure-electron] Install finished but Electron binary is still missing."); + process.exit(1); + } +} + +main(); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index b631c5a31..ea377fec0 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -10,6 +10,7 @@ import { createSessionService } from "./services/sessions/sessionService"; import { createPtyService } from "./services/pty/ptyService"; import { createDiffService } from "./services/diffs/diffService"; import { createFileService } from "./services/files/fileService"; +import { createConflictService } from "./services/conflicts/conflictService"; import { createProjectConfigService } from "./services/config/projectConfigService"; import { createProcessService } from "./services/processes/processService"; import { createTestService } from "./services/tests/testService"; @@ -125,17 +126,22 @@ app.whenReady().then(async () => { const project = toProjectInfo(projectRoot, baseRef); const { projectId } = upsertProjectRow({ db, repoRoot: projectRoot, displayName: project.displayName, baseRef }); + const operationService = createOperationService({ db, projectId }); + let jobEngine: ReturnType | null = null; const laneService = createLaneService({ db, projectRoot, projectId, defaultBaseRef: baseRef, - worktreesDir: adePaths.worktreesDir + worktreesDir: adePaths.worktreesDir, + operationService, + onHeadChanged: ({ laneId, reason }) => { + jobEngine?.onHeadChanged({ laneId, reason }); + } }); await laneService.ensurePrimaryLane(); const sessionService = createSessionService({ db }); const diffService = createDiffService({ laneService }); - const fileService = createFileService({ laneService }); const projectConfigService = createProjectConfigService({ projectRoot, adeDir: adePaths.adeDir, @@ -144,8 +150,6 @@ app.whenReady().then(async () => { logger }); - const operationService = createOperationService({ db, projectId }); - const packService = createPackService({ db, logger, @@ -158,9 +162,27 @@ app.whenReady().then(async () => { operationService }); - const jobEngine = createJobEngine({ + const conflictService = createConflictService({ + db, logger, - packService + projectId, + projectRoot, + laneService, + conflictPacksDir: path.join(adePaths.packsDir, "conflicts"), + onEvent: (event) => broadcast(IPC.conflictsEvent, event) + }); + + jobEngine = createJobEngine({ + logger, + packService, + conflictService + }); + + const fileService = createFileService({ + laneService, + onLaneWorktreeMutation: ({ laneId, reason }) => { + jobEngine.onLaneDirtyChanged({ laneId, reason }); + } }); const ptyService = createPtyService({ @@ -181,6 +203,9 @@ app.whenReady().then(async () => { laneService, operationService, logger, + onWorktreeChanged: ({ laneId, reason }) => { + jobEngine.onLaneDirtyChanged({ laneId, reason }); + }, onHeadChanged: ({ laneId, reason }) => { jobEngine.onHeadChanged({ laneId, reason }); } @@ -227,6 +252,8 @@ app.whenReady().then(async () => { fileService, operationService, gitService, + conflictService, + jobEngine, packService, projectConfigService, processService, @@ -235,6 +262,11 @@ app.whenReady().then(async () => { }; const closeContext = () => { + try { + ctxRef.jobEngine.dispose(); + } catch { + // ignore + } try { ctxRef.fileService.dispose(); } catch { diff --git a/apps/desktop/src/main/services/config/laneOverlayMatcher.ts b/apps/desktop/src/main/services/config/laneOverlayMatcher.ts new file mode 100644 index 000000000..21d9e4168 --- /dev/null +++ b/apps/desktop/src/main/services/config/laneOverlayMatcher.ts @@ -0,0 +1,87 @@ +import type { LaneOverlayOverrides, LaneOverlayPolicy, LaneSummary } from "../../../shared/types"; + +function escapeRegExp(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} + +function globToRegExp(pattern: string): RegExp { + const normalized = pattern.trim(); + if (!normalized.length) return /^$/; + const parts = normalized.split("*").map((chunk) => escapeRegExp(chunk)); + return new RegExp(`^${parts.join(".*")}$`, "i"); +} + +function normalizeSet(values: string[] | undefined): Set { + return new Set((values ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)); +} + +function intersectOrAdopt(current: string[] | undefined, next: string[] | undefined): string[] | undefined { + if (!next || next.length === 0) return current; + if (!current || current.length === 0) return [...next]; + const allowed = new Set(next); + return current.filter((entry) => allowed.has(entry)); +} + +function matchesPolicy(lane: LaneSummary, policy: LaneOverlayPolicy): boolean { + if (!policy.enabled) return false; + const match = policy.match ?? {}; + + if (match.laneIds && match.laneIds.length > 0 && !match.laneIds.includes(lane.id)) { + return false; + } + if (match.laneTypes && match.laneTypes.length > 0 && !match.laneTypes.includes(lane.laneType)) { + return false; + } + if (match.namePattern) { + const pattern = globToRegExp(match.namePattern); + if (!pattern.test(lane.name)) return false; + } + if (match.branchPattern) { + const pattern = globToRegExp(match.branchPattern); + if (!pattern.test(lane.branchRef)) return false; + } + if (match.tags && match.tags.length > 0) { + const laneTags = normalizeSet(lane.tags); + const required = normalizeSet(match.tags); + let matched = false; + for (const tag of required) { + if (laneTags.has(tag)) { + matched = true; + break; + } + } + if (!matched) return false; + } + + return true; +} + +export function matchLaneOverlayPolicies(lane: LaneSummary, policies: LaneOverlayPolicy[]): LaneOverlayOverrides { + const merged: LaneOverlayOverrides = {}; + + for (const policy of policies) { + if (!matchesPolicy(lane, policy)) continue; + const overrides = policy.overrides ?? {}; + if (overrides.env) { + merged.env = { + ...(merged.env ?? {}), + ...overrides.env + }; + } + if (typeof overrides.cwd === "string" && overrides.cwd.trim().length > 0) { + merged.cwd = overrides.cwd.trim(); + } + merged.processIds = intersectOrAdopt(merged.processIds, overrides.processIds); + merged.testSuiteIds = intersectOrAdopt(merged.testSuiteIds, overrides.testSuiteIds); + } + + if (merged.processIds && merged.processIds.length === 0) { + delete merged.processIds; + } + if (merged.testSuiteIds && merged.testSuiteIds.length === 0) { + delete merged.testSuiteIds; + } + + return merged; +} + diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index c46dd669f..91776e13e 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -3,11 +3,16 @@ import path from "node:path"; import { createHash } from "node:crypto"; import YAML from "yaml"; import type { + ConfigLaneOverlayPolicy, ConfigProcessDefinition, ConfigProcessReadiness, ConfigStackButtonDefinition, ConfigTestSuiteDefinition, EffectiveProjectConfig, + LaneOverlayMatch, + LaneOverlayOverrides, + LaneOverlayPolicy, + LaneType, ProcessDefinition, ProcessReadinessConfig, ProjectConfigCandidate, @@ -44,6 +49,15 @@ function asStringArray(value: unknown): string[] | undefined { return out; } +function asLaneTypeArray(value: unknown): LaneType[] | undefined { + const out = asStringArray(value); + if (!out) return undefined; + const laneTypes = out.filter((laneType): laneType is LaneType => + laneType === "primary" || laneType === "worktree" || laneType === "attached" + ); + return laneTypes; +} + function asNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } @@ -146,9 +160,50 @@ function coerceTestSuite(value: unknown): ConfigTestSuiteDefinition | null { return out; } +function coerceLaneOverlayPolicy(value: unknown): ConfigLaneOverlayPolicy | null { + if (!isRecord(value)) return null; + const id = asString(value.id)?.trim() ?? ""; + const out: ConfigLaneOverlayPolicy = { id }; + + const name = asString(value.name); + const enabled = asBool(value.enabled); + if (name != null) out.name = name; + if (enabled != null) out.enabled = enabled; + + if (isRecord(value.match)) { + const match: LaneOverlayMatch = {}; + const laneIds = asStringArray(value.match.laneIds); + const laneTypes = asLaneTypeArray(value.match.laneTypes); + const namePattern = asString(value.match.namePattern); + const branchPattern = asString(value.match.branchPattern); + const tags = asStringArray(value.match.tags); + if (laneIds != null) match.laneIds = laneIds; + if (laneTypes != null) match.laneTypes = laneTypes; + if (namePattern != null) match.namePattern = namePattern; + if (branchPattern != null) match.branchPattern = branchPattern; + if (tags != null) match.tags = tags; + if (Object.keys(match).length > 0) out.match = match; + } + + if (isRecord(value.overrides)) { + const overrides: LaneOverlayOverrides = {}; + const env = asStringMap(value.overrides.env); + const cwd = asString(value.overrides.cwd); + const processIds = asStringArray(value.overrides.processIds); + const testSuiteIds = asStringArray(value.overrides.testSuiteIds); + if (env != null) overrides.env = env; + if (cwd != null) overrides.cwd = cwd; + if (processIds != null) overrides.processIds = processIds; + if (testSuiteIds != null) overrides.testSuiteIds = testSuiteIds; + if (Object.keys(overrides).length > 0) out.overrides = overrides; + } + + return out; +} + function coerceConfigFile(value: unknown): ProjectConfigFile { if (!isRecord(value)) { - return { version: VERSION, processes: [], stackButtons: [], testSuites: [] }; + return { version: VERSION, processes: [], stackButtons: [], testSuites: [], laneOverlayPolicies: [] }; } const version = asNumber(value.version) ?? VERSION; @@ -161,12 +216,16 @@ function coerceConfigFile(value: unknown): ProjectConfigFile { const testSuites = Array.isArray(value.testSuites) ? value.testSuites.map(coerceTestSuite).filter((x): x is ConfigTestSuiteDefinition => x != null) : []; + const laneOverlayPolicies = Array.isArray(value.laneOverlayPolicies) + ? value.laneOverlayPolicies.map(coerceLaneOverlayPolicy).filter((x): x is ConfigLaneOverlayPolicy => x != null) + : []; return { version, processes, stackButtons, testSuites, + laneOverlayPolicies, ...(isRecord(value.providers) ? { providers: value.providers } : {}) }; } @@ -175,13 +234,13 @@ function readConfigFile(filePath: string): { config: ProjectConfigFile; raw: str try { const raw = fs.readFileSync(filePath, "utf8"); if (!raw.trim().length) { - return { config: { version: VERSION, processes: [], stackButtons: [], testSuites: [] }, raw }; + return { config: { version: VERSION, processes: [], stackButtons: [], testSuites: [], laneOverlayPolicies: [] }, raw }; } const parsed = YAML.parse(raw); return { config: coerceConfigFile(parsed), raw }; } catch (err: any) { if (err?.code === "ENOENT") { - return { config: { version: VERSION, processes: [], stackButtons: [], testSuites: [] }, raw: "" }; + return { config: { version: VERSION, processes: [], stackButtons: [], testSuites: [], laneOverlayPolicies: [] }, raw: "" }; } throw err; } @@ -193,6 +252,7 @@ function toCanonicalYaml(config: ProjectConfigFile): string { processes: config.processes ?? [], stackButtons: config.stackButtons ?? [], testSuites: config.testSuites ?? [], + laneOverlayPolicies: config.laneOverlayPolicies ?? [], ...(config.providers ? { providers: config.providers } : {}) }; return YAML.stringify(normalized, { indent: 2 }); @@ -261,6 +321,19 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF ...(base.env || over.env ? { env: { ...(base.env ?? {}), ...(over.env ?? {}) } } : {}) })); + const mergedLaneOverlayPolicies = mergeById( + shared.laneOverlayPolicies ?? [], + local.laneOverlayPolicies ?? [], + (base, over) => ({ + ...base, + ...over, + ...(base.match || over.match ? { match: { ...(base.match ?? {}), ...(over.match ?? {}) } } : {}), + ...(base.overrides || over.overrides + ? { overrides: { ...(base.overrides ?? {}), ...(over.overrides ?? {}) } } + : {}) + }) + ); + const processes: ProcessDefinition[] = mergedProcesses.map((entry) => ({ id: entry.id.trim(), name: entry.name?.trim() ?? "", @@ -291,6 +364,25 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF tags: entry.tags ?? [] })); + const laneOverlayPolicies: LaneOverlayPolicy[] = mergedLaneOverlayPolicies.map((entry) => ({ + id: entry.id.trim(), + name: entry.name?.trim() ?? entry.id.trim(), + enabled: entry.enabled ?? true, + match: { + ...(entry.match?.laneIds ? { laneIds: entry.match.laneIds.map((v) => v.trim()).filter(Boolean) } : {}), + ...(entry.match?.laneTypes ? { laneTypes: entry.match.laneTypes } : {}), + ...(entry.match?.namePattern ? { namePattern: entry.match.namePattern.trim() } : {}), + ...(entry.match?.branchPattern ? { branchPattern: entry.match.branchPattern.trim() } : {}), + ...(entry.match?.tags ? { tags: entry.match.tags.map((v) => v.trim()).filter(Boolean) } : {}) + }, + overrides: { + ...(entry.overrides?.env ? { env: entry.overrides.env } : {}), + ...(entry.overrides?.cwd ? { cwd: entry.overrides.cwd.trim() } : {}), + ...(entry.overrides?.processIds ? { processIds: entry.overrides.processIds.map((v) => v.trim()).filter(Boolean) } : {}), + ...(entry.overrides?.testSuiteIds ? { testSuiteIds: entry.overrides.testSuiteIds.map((v) => v.trim()).filter(Boolean) } : {}) + } + })); + const mergedProviders = shared.providers || local.providers ? { ...(shared.providers ?? {}), @@ -307,6 +399,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF processes, stackButtons, testSuites, + laneOverlayPolicies, providerMode, ...(mergedProviders ? { providers: mergedProviders } : {}) }; @@ -384,6 +477,8 @@ function validateEffectiveConfig( validateDuplicateIds(local.stackButtons ?? [], "stackButtons", issues, "local"); validateDuplicateIds(shared.testSuites ?? [], "testSuites", issues, "shared"); validateDuplicateIds(local.testSuites ?? [], "testSuites", issues, "local"); + validateDuplicateIds(shared.laneOverlayPolicies ?? [], "laneOverlayPolicies", issues, "shared"); + validateDuplicateIds(local.laneOverlayPolicies ?? [], "laneOverlayPolicies", issues, "local"); const processIds = new Set(); for (const [idx, proc] of effective.processes.entries()) { @@ -487,6 +582,40 @@ function validateEffectiveConfig( } } + const overlayIds = new Set(); + for (const [idx, policy] of effective.laneOverlayPolicies.entries()) { + const p = `effective.laneOverlayPolicies[${idx}]`; + if (!policy.id) { + issues.push({ path: `${p}.id`, message: "Lane overlay policy id is required" }); + continue; + } + if (overlayIds.has(policy.id)) { + issues.push({ path: `${p}.id`, message: `Duplicate lane overlay policy id '${policy.id}'` }); + } else { + overlayIds.add(policy.id); + } + if (!policy.name) { + issues.push({ path: `${p}.name`, message: "Lane overlay policy name is required" }); + } + const overrideCwd = policy.overrides.cwd; + if (overrideCwd) { + const absCwd = path.isAbsolute(overrideCwd) ? overrideCwd : path.join(projectRoot, overrideCwd); + if (!isDirectory(absCwd)) { + issues.push({ path: `${p}.overrides.cwd`, message: `cwd override does not exist: ${overrideCwd}` }); + } + } + for (const processId of policy.overrides.processIds ?? []) { + if (!processIds.has(processId)) { + issues.push({ path: `${p}.overrides.processIds`, message: `Unknown process id '${processId}'` }); + } + } + for (const suiteId of policy.overrides.testSuiteIds ?? []) { + if (!suiteIds.has(suiteId)) { + issues.push({ path: `${p}.overrides.testSuiteIds`, message: `Unknown test suite id '${suiteId}'` }); + } + } + } + return { ok: issues.length === 0, issues diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts new file mode 100644 index 000000000..f75f5eb21 --- /dev/null +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -0,0 +1,1064 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { + BatchOverlapEntry, + BatchAssessmentResult, + ConflictChip, + ConflictEventPayload, + ConflictFileType, + ConflictOverlap, + ConflictPrediction, + ConflictRiskLevel, + ConflictStatus, + ConflictStatusValue, + GetLaneConflictStatusArgs, + LaneSummary, + ListOverlapsArgs, + MergeSimulationArgs, + MergeSimulationResult, + RiskMatrixEntry, + RunConflictPredictionArgs +} from "../../../shared/types"; +import type { Logger } from "../logging/logger"; +import type { AdeDb } from "../state/kvDb"; +import type { createLaneService } from "../lanes/laneService"; +import { runGit, runGitMergeTree, runGitOrThrow } from "../git/git"; + +type PredictionStatus = "clean" | "conflict" | "unknown"; + +type ConflictPredictionRow = { + id: string; + lane_a_id: string; + lane_b_id: string | null; + status: PredictionStatus; + conflicting_files_json: string | null; + overlap_files_json: string | null; + lane_a_sha: string | null; + lane_b_sha: string | null; + predicted_at: string; + expires_at: string | null; +}; + +type StoredConflictFile = { + path: string; + conflictType: string; + markerPreview?: string; +}; + +const RISK_SCORE: Record = { + none: 0, + low: 1, + medium: 2, + high: 3 +}; + +const MAX_AUTO_LANES = 15; +const STALE_MS = 5 * 60_000; + +function safeJsonArray(raw: string | null): T[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +function toIsoPlusMinutes(minutes: number): string { + return new Date(Date.now() + minutes * 60_000).toISOString(); +} + +function pairKey(a: string, b: string): string { + return a < b ? `${a}::${b}` : `${b}::${a}`; +} + +function matrixEntryKey(entry: RiskMatrixEntry): string { + return pairKey(entry.laneAId, entry.laneBId); +} + +function normalizeConflictType(value: string): ConflictFileType { + const normalized = value.trim().toLowerCase(); + if (normalized.includes("rename")) return "rename"; + if (normalized.includes("delete")) return "delete"; + if (normalized.includes("add")) return "add"; + return "content"; +} + +function riskFromPrediction(status: PredictionStatus, overlapCount: number, conflictCount: number): ConflictRiskLevel { + if (status === "conflict" || conflictCount > 0) return "high"; + if (overlapCount === 0) return "none"; + if (overlapCount <= 2) return "low"; + if (overlapCount <= 6) return "medium"; + return "high"; +} + +function isStalePrediction(predictedAt: string | null | undefined): boolean { + if (!predictedAt) return true; + const ts = Date.parse(predictedAt); + if (Number.isNaN(ts)) return true; + return Date.now() - ts > STALE_MS; +} + +function extractOverlapFiles(row: ConflictPredictionRow | undefined): string[] { + if (!row) return []; + const overlaps = safeJsonArray(row.overlap_files_json ?? null); + const conflicting = safeJsonArray(row.conflicting_files_json ?? null); + return uniqueSorted([ + ...overlaps.map((value) => value.trim()).filter(Boolean), + ...conflicting.map((value) => value.path?.trim() ?? "").filter(Boolean) + ]); +} + +function parseDiffNameOnly(stdout: string): string[] { + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +async function readHeadSha(cwd: string, ref = "HEAD"): Promise { + return (await runGitOrThrow(["rev-parse", ref], { cwd, timeoutMs: 10_000 })).trim(); +} + +async function readMergeBase(cwd: string, refA: string, refB: string): Promise { + return (await runGitOrThrow(["merge-base", refA, refB], { cwd, timeoutMs: 10_000 })).trim(); +} + +async function readTouchedFiles(cwd: string, mergeBase: string, headSha: string): Promise> { + const res = await runGit(["diff", "--name-only", `${mergeBase}..${headSha}`], { cwd, timeoutMs: 15_000 }); + if (res.exitCode !== 0) return new Set(); + return new Set(parseDiffNameOnly(res.stdout)); +} + +async function readDiffNumstat(cwd: string, mergeBase: string, headSha: string): Promise<{ + files: Set; + insertions: number; + deletions: number; +}> { + const res = await runGit(["diff", "--numstat", `${mergeBase}..${headSha}`], { + cwd, + timeoutMs: 15_000 + }); + if (res.exitCode !== 0) { + return { + files: new Set(), + insertions: 0, + deletions: 0 + }; + } + + const files = new Set(); + let insertions = 0; + let deletions = 0; + for (const rawLine of res.stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const [insRaw, delRaw, file] = line.split(/\t/); + if (!file) continue; + files.add(file); + const ins = Number(insRaw); + const del = Number(delRaw); + if (Number.isFinite(ins)) insertions += ins; + if (Number.isFinite(del)) deletions += del; + } + return { files, insertions, deletions }; +} + +function uniqueSorted(values: Iterable): string[] { + return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)); +} + +function latestPerPair(rows: ConflictPredictionRow[]): Map { + const out = new Map(); + for (const row of rows) { + const key = + row.lane_b_id == null + ? `base:${row.lane_a_id}` + : `pair:${pairKey(row.lane_a_id, row.lane_b_id)}`; + if (!out.has(key)) { + out.set(key, row); + continue; + } + const current = out.get(key)!; + if (row.predicted_at > current.predicted_at) { + out.set(key, row); + } + } + return out; +} + +function computeStatusValue(args: { + hasActiveConflict: boolean; + hasBasePrediction: boolean; + hasPredictedConflict: boolean; + behindCount: number; +}): ConflictStatusValue { + if (args.hasActiveConflict) return "conflict-active"; + if (!args.hasBasePrediction) return "unknown"; + if (args.hasPredictedConflict) return "conflict-predicted"; + if (args.behindCount > 0) return "behind-base"; + return "merge-ready"; +} + +function laneById(lanes: LaneSummary[]): Map { + return new Map(lanes.map((lane) => [lane.id, lane])); +} + +function buildConflictFiles(conflicting: StoredConflictFile[], overlapFiles: string[]): Array<{ + path: string; + conflictType: ConflictFileType; + markerPreview: string; +}> { + const seen = new Set(); + const out: Array<{ path: string; conflictType: ConflictFileType; markerPreview: string }> = []; + + for (const file of conflicting) { + const clean = file.path?.trim(); + if (!clean || seen.has(clean)) continue; + seen.add(clean); + out.push({ + path: clean, + conflictType: normalizeConflictType(file.conflictType ?? "content"), + markerPreview: file.markerPreview ?? "" + }); + } + + for (const path of overlapFiles) { + const clean = path.trim(); + if (!clean || seen.has(clean)) continue; + seen.add(clean); + out.push({ + path: clean, + conflictType: "content", + markerPreview: "" + }); + } + + out.sort((a, b) => a.path.localeCompare(b.path)); + return out; +} + +function dedupeChips(chips: ConflictChip[]): ConflictChip[] { + const map = new Map(); + for (const chip of chips) { + const key = `${chip.laneId}:${chip.peerId ?? "base"}:${chip.kind}`; + const existing = map.get(key); + if (!existing || chip.overlapCount > existing.overlapCount) { + map.set(key, chip); + } + } + return Array.from(map.values()); +} + +export function createConflictService({ + db, + logger, + projectId, + projectRoot, + laneService, + conflictPacksDir, + onEvent +}: { + db: AdeDb; + logger: Logger; + projectId: string; + projectRoot: string; + laneService: ReturnType; + conflictPacksDir?: string; + onEvent?: (event: ConflictEventPayload) => void; +}) { + const pairLocks = new Map>(); + const pairQueued = new Set(); + + const runSerializedPairTask = async (pairId: string, task: () => Promise): Promise => { + const active = pairLocks.get(pairId); + if (active) { + pairQueued.add(pairId); + await active; + if (!pairQueued.has(pairId)) return; + pairQueued.delete(pairId); + } + + const running = (async () => { + await task(); + })().finally(() => { + const current = pairLocks.get(pairId); + if (current === running) { + pairLocks.delete(pairId); + } + }); + + pairLocks.set(pairId, running); + await running; + + if (pairQueued.has(pairId)) { + pairQueued.delete(pairId); + await runSerializedPairTask(pairId, task); + } + }; + + const listActiveLanes = async (): Promise => { + const lanes = await laneService.list({ includeArchived: false }); + return lanes.filter((lane) => !lane.archivedAt); + }; + + const getLatestRows = (): Map => { + const rows = db.all( + ` + select + id, + lane_a_id, + lane_b_id, + status, + conflicting_files_json, + overlap_files_json, + lane_a_sha, + lane_b_sha, + predicted_at, + expires_at + from conflict_predictions + where project_id = ? + order by predicted_at desc + `, + [projectId] + ); + return latestPerPair(rows); + }; + + const getLatestBaseRow = (laneId: string): ConflictPredictionRow | null => { + return db.get( + ` + select + id, + lane_a_id, + lane_b_id, + status, + conflicting_files_json, + overlap_files_json, + lane_a_sha, + lane_b_sha, + predicted_at, + expires_at + from conflict_predictions + where project_id = ? + and lane_a_id = ? + and lane_b_id is null + order by predicted_at desc + limit 1 + `, + [projectId, laneId] + ); + }; + + const getLatestPairRowsForLane = (laneId: string): ConflictPredictionRow[] => { + return db.all( + ` + select + id, + lane_a_id, + lane_b_id, + status, + conflicting_files_json, + overlap_files_json, + lane_a_sha, + lane_b_sha, + predicted_at, + expires_at + from conflict_predictions + where project_id = ? + and lane_b_id is not null + and (lane_a_id = ? or lane_b_id = ?) + order by predicted_at desc + `, + [projectId, laneId, laneId] + ); + }; + + const upsertPrediction = (args: { + laneAId: string; + laneBId: string | null; + status: PredictionStatus; + conflictingFiles: StoredConflictFile[]; + overlapFiles: string[]; + laneASha: string; + laneBSha: string | null; + }): ConflictPrediction => { + const id = randomUUID(); + const predictedAt = new Date().toISOString(); + const expiresAt = toIsoPlusMinutes(30); + const conflictingFiles = args.conflictingFiles.map((file) => ({ + path: file.path, + conflictType: file.conflictType + })); + const overlapFiles = uniqueSorted(args.overlapFiles); + + db.run( + ` + insert into conflict_predictions( + id, + project_id, + lane_a_id, + lane_b_id, + status, + conflicting_files_json, + overlap_files_json, + lane_a_sha, + lane_b_sha, + predicted_at, + expires_at + ) values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + id, + projectId, + args.laneAId, + args.laneBId, + args.status, + JSON.stringify(conflictingFiles), + JSON.stringify(overlapFiles), + args.laneASha, + args.laneBSha, + predictedAt, + expiresAt + ] + ); + + if (args.laneBId == null) { + db.run( + ` + delete from conflict_predictions + where project_id = ? + and lane_a_id = ? + and lane_b_id is null + and id != ? + `, + [projectId, args.laneAId, id] + ); + } else { + db.run( + ` + delete from conflict_predictions + where project_id = ? + and lane_a_id = ? + and lane_b_id = ? + and id != ? + `, + [projectId, args.laneAId, args.laneBId, id] + ); + } + + return { + id, + laneAId: args.laneAId, + laneBId: args.laneBId, + status: args.status, + conflictingFiles, + overlapFiles, + laneASha: args.laneASha, + laneBSha: args.laneBSha, + predictedAt + }; + }; + + const hasActiveConflict = async (lane: LaneSummary): Promise => { + const res = await runGit(["ls-files", "-u"], { cwd: lane.worktreePath, timeoutMs: 8_000 }); + if (res.exitCode !== 0) return false; + return res.stdout.trim().length > 0; + }; + + const predictLaneVsBase = async (lane: LaneSummary): Promise => { + const laneHead = await readHeadSha(lane.worktreePath, "HEAD"); + const baseHead = await readHeadSha(projectRoot, lane.baseRef); + const mergeBase = await readMergeBase(projectRoot, baseHead, laneHead); + const merge = await runGitMergeTree({ + cwd: projectRoot, + mergeBase, + branchA: baseHead, + branchB: laneHead, + timeoutMs: 60_000 + }); + + const [baseTouched, laneTouched] = await Promise.all([ + readTouchedFiles(projectRoot, mergeBase, baseHead), + readTouchedFiles(projectRoot, mergeBase, laneHead) + ]); + const overlap = uniqueSorted(Array.from(laneTouched).filter((file) => baseTouched.has(file))); + const conflicts = merge.conflicts.map((conflict) => ({ + path: conflict.path, + conflictType: conflict.conflictType, + markerPreview: conflict.markerPreview + })); + const status: PredictionStatus = + conflicts.length > 0 ? "conflict" : merge.exitCode === 0 ? "clean" : "unknown"; + + return upsertPrediction({ + laneAId: lane.id, + laneBId: null, + status, + conflictingFiles: conflicts, + overlapFiles: overlap, + laneASha: laneHead, + laneBSha: baseHead + }); + }; + + const predictPairwise = async (laneA: LaneSummary, laneB: LaneSummary): Promise => { + const laneAHead = await readHeadSha(laneA.worktreePath, "HEAD"); + const laneBHead = await readHeadSha(laneB.worktreePath, "HEAD"); + const mergeBase = await readMergeBase(projectRoot, laneAHead, laneBHead); + const merge = await runGitMergeTree({ + cwd: projectRoot, + mergeBase, + branchA: laneAHead, + branchB: laneBHead, + timeoutMs: 60_000 + }); + + const [aTouched, bTouched] = await Promise.all([ + readTouchedFiles(projectRoot, mergeBase, laneAHead), + readTouchedFiles(projectRoot, mergeBase, laneBHead) + ]); + const overlap = uniqueSorted(Array.from(aTouched).filter((file) => bTouched.has(file))); + const conflicts = merge.conflicts.map((conflict) => ({ + path: conflict.path, + conflictType: conflict.conflictType, + markerPreview: conflict.markerPreview + })); + const status: PredictionStatus = + conflicts.length > 0 ? "conflict" : merge.exitCode === 0 ? "clean" : "unknown"; + + const [leftLane, rightLane, leftSha, rightSha] = + laneA.id < laneB.id + ? [laneA, laneB, laneAHead, laneBHead] + : [laneB, laneA, laneBHead, laneAHead]; + + return upsertPrediction({ + laneAId: leftLane.id, + laneBId: rightLane.id, + status, + conflictingFiles: conflicts, + overlapFiles: overlap, + laneASha: leftSha, + laneBSha: rightSha + }); + }; + + const getLaneStatusInternal = async (lane: LaneSummary): Promise => { + const baseRow = getLatestBaseRow(lane.id); + const pairRows = latestPerPair(getLatestPairRowsForLane(lane.id)); + + const overlapSet = new Set(); + let peerConflictCount = 0; + + const foldRow = (row: ConflictPredictionRow) => { + const conflicting = safeJsonArray(row.conflicting_files_json); + const overlapFiles = safeJsonArray(row.overlap_files_json); + for (const path of overlapFiles) { + const clean = path.trim(); + if (clean) overlapSet.add(clean); + } + for (const file of conflicting) { + const clean = file.path?.trim(); + if (clean) overlapSet.add(clean); + } + if (row.status === "conflict" && row.lane_b_id) { + peerConflictCount += 1; + } + }; + + if (baseRow) foldRow(baseRow); + for (const [key, row] of pairRows) { + if (!key.startsWith("pair:")) continue; + foldRow(row); + } + + const hasPredictedConflict = + (baseRow?.status === "conflict") || + Array.from(pairRows.values()).some((row) => row.lane_b_id != null && row.status === "conflict"); + const activeConflict = await hasActiveConflict(lane); + const status = computeStatusValue({ + hasActiveConflict: activeConflict, + hasBasePrediction: Boolean(baseRow), + hasPredictedConflict, + behindCount: lane.status.behind + }); + + const lastPredictedAt = [ + baseRow?.predicted_at ?? null, + ...Array.from(pairRows.values()).map((row) => row.predicted_at) + ] + .filter((value): value is string => Boolean(value)) + .sort((a, b) => b.localeCompare(a))[0] ?? null; + + return { + laneId: lane.id, + status, + overlappingFileCount: overlapSet.size, + peerConflictCount, + lastPredictedAt + }; + }; + + const getRiskMatrixAndOverlaps = async (lanes: LaneSummary[]): Promise<{ + matrix: RiskMatrixEntry[]; + overlaps: BatchOverlapEntry[]; + }> => { + const latest = getLatestRows(); + const matrix: RiskMatrixEntry[] = []; + const overlapEntries: BatchOverlapEntry[] = []; + + for (const lane of lanes) { + const row = latest.get(`base:${lane.id}`); + const overlapFiles = extractOverlapFiles(row); + const conflicting = safeJsonArray(row?.conflicting_files_json ?? null); + matrix.push({ + laneAId: lane.id, + laneBId: lane.id, + riskLevel: riskFromPrediction(row?.status ?? "unknown", overlapFiles.length, conflicting.length), + overlapCount: overlapFiles.length, + hasConflict: (row?.status ?? "unknown") === "conflict" || conflicting.length > 0, + computedAt: row?.predicted_at ?? null, + stale: isStalePrediction(row?.predicted_at) + }); + overlapEntries.push({ + laneAId: lane.id, + laneBId: lane.id, + files: overlapFiles + }); + } + + for (let i = 0; i < lanes.length; i++) { + for (let j = i + 1; j < lanes.length; j++) { + const laneA = lanes[i]!; + const laneB = lanes[j]!; + const key = `pair:${pairKey(laneA.id, laneB.id)}`; + const row = latest.get(key); + const overlapFiles = extractOverlapFiles(row); + const conflicting = safeJsonArray(row?.conflicting_files_json ?? null); + matrix.push({ + laneAId: laneA.id, + laneBId: laneB.id, + riskLevel: riskFromPrediction(row?.status ?? "unknown", overlapFiles.length, conflicting.length), + overlapCount: overlapFiles.length, + hasConflict: (row?.status ?? "unknown") === "conflict" || conflicting.length > 0, + computedAt: row?.predicted_at ?? null, + stale: isStalePrediction(row?.predicted_at) + }); + overlapEntries.push({ + laneAId: laneA.id, + laneBId: laneB.id, + files: overlapFiles + }); + } + } + + return { + matrix, + overlaps: overlapEntries + }; + }; + + const buildBatchAssessment = async (options: { + progress?: { completedPairs: number; totalPairs: number }; + truncated?: boolean; + comparedLaneIds?: string[]; + maxAutoLanes?: number; + totalLanes?: number; + } = {}): Promise => { + const lanes = await listActiveLanes(); + const statuses = await Promise.all(lanes.map((lane) => getLaneStatusInternal(lane))); + const { matrix, overlaps } = await getRiskMatrixAndOverlaps(lanes); + return { + lanes: statuses, + matrix, + overlaps, + computedAt: new Date().toISOString(), + progress: options.progress, + truncated: options.truncated, + comparedLaneIds: options.comparedLaneIds, + maxAutoLanes: options.maxAutoLanes, + totalLanes: options.totalLanes + }; + }; + + const buildChips = (prev: RiskMatrixEntry[], next: RiskMatrixEntry[]): ConflictChip[] => { + const prevMap = new Map(prev.map((entry) => [matrixEntryKey(entry), entry])); + const chips: ConflictChip[] = []; + + for (const entry of next) { + if (entry.laneAId === entry.laneBId) continue; + const key = matrixEntryKey(entry); + const previous = prevMap.get(key); + + const isNewOverlap = entry.overlapCount > 0 && (previous == null || previous.overlapCount === 0); + if (isNewOverlap) { + chips.push( + { laneId: entry.laneAId, peerId: entry.laneBId, kind: "new-overlap", overlapCount: entry.overlapCount }, + { laneId: entry.laneBId, peerId: entry.laneAId, kind: "new-overlap", overlapCount: entry.overlapCount } + ); + } + + const becameHighRisk = + entry.riskLevel === "high" && (previous == null || RISK_SCORE[previous.riskLevel] < RISK_SCORE.high); + if (becameHighRisk) { + chips.push( + { laneId: entry.laneAId, peerId: entry.laneBId, kind: "high-risk", overlapCount: entry.overlapCount }, + { laneId: entry.laneBId, peerId: entry.laneAId, kind: "high-risk", overlapCount: entry.overlapCount } + ); + } + } + + return dedupeChips(chips); + }; + + const writeConflictPacks = async (assessment: BatchAssessmentResult): Promise => { + if (!conflictPacksDir) return; + fs.mkdirSync(conflictPacksDir, { recursive: true }); + + for (const status of assessment.lanes) { + try { + const overlaps = await listOverlaps({ laneId: status.laneId }); + const laneMatrix = assessment.matrix.filter( + (entry) => entry.laneAId === status.laneId || entry.laneBId === status.laneId + ); + const payload = { + laneId: status.laneId, + status, + overlaps, + matrix: laneMatrix, + generatedAt: assessment.computedAt + }; + const outPath = path.join(conflictPacksDir, `${status.laneId}.json`); + fs.writeFileSync(outPath, JSON.stringify(payload, null, 2), "utf8"); + } catch (error) { + logger.warn("conflicts.pack_write_failed", { + laneId: status.laneId, + error: error instanceof Error ? error.message : String(error) + }); + } + } + }; + + const getLaneStatus = async (args: GetLaneConflictStatusArgs): Promise => { + const lane = (await listActiveLanes()).find((entry) => entry.id === args.laneId); + if (!lane) { + throw new Error(`Lane not found: ${args.laneId}`); + } + return await getLaneStatusInternal(lane); + }; + + const listOverlaps = async (args: ListOverlapsArgs): Promise => { + const lanes = await listActiveLanes(); + const lane = lanes.find((entry) => entry.id === args.laneId); + if (!lane) throw new Error(`Lane not found: ${args.laneId}`); + const laneMap = laneById(lanes); + + const overlaps: ConflictOverlap[] = []; + const baseRow = getLatestBaseRow(args.laneId); + if (baseRow) { + const conflicting = safeJsonArray(baseRow.conflicting_files_json); + const overlapFiles = safeJsonArray(baseRow.overlap_files_json); + const files = buildConflictFiles(conflicting, overlapFiles).map((file) => ({ + path: file.path, + conflictType: file.conflictType + })); + overlaps.push({ + peerId: null, + peerName: `base (${lane.baseRef})`, + files, + riskLevel: riskFromPrediction(baseRow.status, overlapFiles.length, conflicting.length) + }); + } + + const latest = latestPerPair(getLatestPairRowsForLane(args.laneId)); + for (const [key, row] of latest) { + if (!key.startsWith("pair:") || row.lane_b_id == null) continue; + const peerId = row.lane_a_id === args.laneId ? row.lane_b_id : row.lane_a_id; + const peerLane = laneMap.get(peerId); + const conflicting = safeJsonArray(row.conflicting_files_json); + const overlapFiles = safeJsonArray(row.overlap_files_json); + const files = buildConflictFiles(conflicting, overlapFiles).map((file) => ({ + path: file.path, + conflictType: file.conflictType + })); + overlaps.push({ + peerId, + peerName: peerLane?.name ?? "Unknown lane", + files, + riskLevel: riskFromPrediction(row.status, overlapFiles.length, conflicting.length) + }); + } + + overlaps.sort((a, b) => { + const riskDelta = RISK_SCORE[b.riskLevel] - RISK_SCORE[a.riskLevel]; + if (riskDelta !== 0) return riskDelta; + return a.peerName.localeCompare(b.peerName); + }); + return overlaps; + }; + + const getRiskMatrix = async (): Promise => { + const lanes = await listActiveLanes(); + return (await getRiskMatrixAndOverlaps(lanes)).matrix; + }; + + const simulateMerge = async (args: MergeSimulationArgs): Promise => { + const lanes = await listActiveLanes(); + const laneA = lanes.find((entry) => entry.id === args.laneAId); + if (!laneA) { + return { + outcome: "error", + mergedFiles: [], + conflictingFiles: [], + diffStat: { insertions: 0, deletions: 0, filesChanged: 0 }, + error: `Lane not found: ${args.laneAId}` + }; + } + + try { + const laneAHead = await readHeadSha(laneA.worktreePath, "HEAD"); + + let laneBHead: string; + if (args.laneBId) { + const laneB = lanes.find((entry) => entry.id === args.laneBId); + if (!laneB) { + return { + outcome: "error", + mergedFiles: [], + conflictingFiles: [], + diffStat: { insertions: 0, deletions: 0, filesChanged: 0 }, + error: `Lane not found: ${args.laneBId}` + }; + } + laneBHead = await readHeadSha(laneB.worktreePath, "HEAD"); + } else { + laneBHead = await readHeadSha(projectRoot, laneA.baseRef); + } + + const mergeBase = await readMergeBase(projectRoot, laneAHead, laneBHead); + const merge = await runGitMergeTree({ + cwd: projectRoot, + mergeBase, + branchA: laneAHead, + branchB: laneBHead, + timeoutMs: 60_000 + }); + + const [statA, statB, touchedA, touchedB] = await Promise.all([ + readDiffNumstat(projectRoot, mergeBase, laneAHead), + readDiffNumstat(projectRoot, mergeBase, laneBHead), + readTouchedFiles(projectRoot, mergeBase, laneAHead), + readTouchedFiles(projectRoot, mergeBase, laneBHead) + ]); + + const mergedFiles = uniqueSorted(new Set([...touchedA, ...touchedB])); + const overlapFiles = uniqueSorted(Array.from(touchedA).filter((file) => touchedB.has(file))); + const conflictFiles = buildConflictFiles( + merge.conflicts.map((entry) => ({ + path: entry.path, + conflictType: entry.conflictType, + markerPreview: entry.markerPreview + })), + merge.exitCode === 0 ? [] : overlapFiles + ); + + return { + outcome: conflictFiles.length > 0 ? "conflict" : merge.exitCode === 0 ? "clean" : "error", + mergedFiles, + conflictingFiles: conflictFiles.map((file) => ({ + path: file.path, + conflictMarkers: file.markerPreview + })), + diffStat: { + insertions: statA.insertions + statB.insertions, + deletions: statA.deletions + statB.deletions, + filesChanged: new Set([...statA.files, ...statB.files]).size + }, + error: merge.exitCode === 0 ? undefined : merge.stderr.trim() || undefined + }; + } catch (error) { + return { + outcome: "error", + mergedFiles: [], + conflictingFiles: [], + diffStat: { insertions: 0, deletions: 0, filesChanged: 0 }, + error: error instanceof Error ? error.message : String(error) + }; + } + }; + + const runPrediction = async (args: RunConflictPredictionArgs = {}): Promise => { + const lanes = await listActiveLanes(); + if (lanes.length === 0) { + return { + lanes: [], + matrix: [], + overlaps: [], + computedAt: new Date().toISOString(), + progress: { completedPairs: 0, totalPairs: 0 } + }; + } + + const before = await buildBatchAssessment(); + const targetLane = args.laneId ? lanes.find((lane) => lane.id === args.laneId) : null; + if (args.laneId && !targetLane) { + throw new Error(`Lane not found: ${args.laneId}`); + } + + const requestedLaneIds = uniqueSorted( + (args.laneIds ?? []) + .map((laneId) => laneId.trim()) + .filter(Boolean) + ); + + let autoLimited = false; + let comparisonLanes: LaneSummary[] = []; + let basePredictionLanes: LaneSummary[] = []; + let totalPairs = 0; + + if (targetLane) { + comparisonLanes = lanes; + basePredictionLanes = [targetLane]; + totalPairs = Math.max(0, lanes.length - 1); + } else if (requestedLaneIds.length > 0) { + const requestedSet = new Set(requestedLaneIds); + const selected = lanes.filter((lane) => requestedSet.has(lane.id)); + if (selected.length === 0) { + throw new Error("No valid lanes selected for conflict prediction"); + } + autoLimited = selected.length > MAX_AUTO_LANES; + comparisonLanes = selected.slice(0, autoLimited ? MAX_AUTO_LANES : selected.length); + basePredictionLanes = comparisonLanes; + totalPairs = Math.max(0, (comparisonLanes.length * (comparisonLanes.length - 1)) / 2); + } else { + autoLimited = lanes.length > MAX_AUTO_LANES; + comparisonLanes = lanes.slice(0, autoLimited ? MAX_AUTO_LANES : lanes.length); + basePredictionLanes = comparisonLanes; + totalPairs = Math.max(0, (comparisonLanes.length * (comparisonLanes.length - 1)) / 2); + } + let completedPairs = 0; + + const emitProgress = (pair?: { laneAId: string; laneBId: string }) => { + if (!onEvent) return; + onEvent({ + type: "prediction-progress", + computedAt: new Date().toISOString(), + laneIds: comparisonLanes.map((lane) => lane.id), + completedPairs, + totalPairs, + pair + }); + }; + + for (const lane of basePredictionLanes) { + try { + await runSerializedPairTask(`base:${lane.id}`, async () => { + await predictLaneVsBase(lane); + }); + } catch (error) { + logger.warn("conflicts.predict_lane_base_failed", { + laneId: lane.id, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + if (targetLane) { + for (const peer of lanes) { + if (peer.id === targetLane.id) continue; + try { + const pairId = `pair:${pairKey(targetLane.id, peer.id)}`; + await runSerializedPairTask(pairId, async () => { + await predictPairwise(targetLane, peer); + }); + } catch (error) { + logger.warn("conflicts.predict_pair_failed", { + laneId: targetLane.id, + peerId: peer.id, + error: error instanceof Error ? error.message : String(error) + }); + } + completedPairs += 1; + emitProgress({ laneAId: targetLane.id, laneBId: peer.id }); + } + } else { + for (let i = 0; i < comparisonLanes.length; i++) { + for (let j = i + 1; j < comparisonLanes.length; j++) { + const laneA = comparisonLanes[i]!; + const laneB = comparisonLanes[j]!; + try { + const pairId = `pair:${pairKey(laneA.id, laneB.id)}`; + await runSerializedPairTask(pairId, async () => { + await predictPairwise(laneA, laneB); + }); + } catch (error) { + logger.warn("conflicts.predict_pair_failed", { + laneId: laneA.id, + peerId: laneB.id, + error: error instanceof Error ? error.message : String(error) + }); + } + completedPairs += 1; + emitProgress({ laneAId: laneA.id, laneBId: laneB.id }); + } + } + } + + const after = await buildBatchAssessment({ + progress: { completedPairs, totalPairs }, + truncated: targetLane ? false : autoLimited, + comparedLaneIds: comparisonLanes.map((lane) => lane.id), + maxAutoLanes: targetLane ? undefined : autoLimited ? MAX_AUTO_LANES : undefined, + totalLanes: lanes.length + }); + await writeConflictPacks(after); + const chips = buildChips(before.matrix, after.matrix); + if (onEvent) { + const relatedPeerIds = chips + .map((chip) => chip.peerId) + .filter((peerId): peerId is string => Boolean(peerId)); + const laneIds = targetLane + ? uniqueSorted([targetLane.id, ...relatedPeerIds]) + : comparisonLanes.map((lane) => lane.id); + onEvent({ + type: "prediction-complete", + computedAt: after.computedAt, + laneIds, + chips, + completedPairs, + totalPairs + }); + } + return after; + }; + + const getBatchAssessment = async (): Promise => { + const hasAny = db.get<{ id: string }>( + "select id from conflict_predictions where project_id = ? limit 1", + [projectId] + ); + if (!hasAny) { + return await runPrediction({}); + } + const lanes = await listActiveLanes(); + const autoLimited = lanes.length > MAX_AUTO_LANES; + const comparedLaneIds = lanes.slice(0, autoLimited ? MAX_AUTO_LANES : lanes.length).map((lane) => lane.id); + return await buildBatchAssessment({ + truncated: autoLimited, + comparedLaneIds, + maxAutoLanes: autoLimited ? MAX_AUTO_LANES : undefined, + totalLanes: lanes.length + }); + }; + + return { + getLaneStatus, + listOverlaps, + getRiskMatrix, + simulateMerge, + runPrediction, + getBatchAssessment + }; +} diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index 79330ba20..2b6b3118f 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -108,7 +108,13 @@ function inferDirectoryStatus(statusMap: Map, relP return null; } -export function createFileService({ laneService }: { laneService: ReturnType }) { +export function createFileService({ + laneService, + onLaneWorktreeMutation +}: { + laneService: ReturnType; + onLaneWorktreeMutation?: (args: { laneId: string; reason: string }) => void; +}) { const watcherService = createFileWatcherService(); const indexService = createFileSearchIndexService(); const ignoreCache = new Map(); @@ -124,6 +130,16 @@ export function createFileService({ laneService }: { laneService: ReturnType laneService.resolveWorkspaceById(workspaceId); + const emitLaneMutation = (workspaceId: string, reason: string) => { + if (!onLaneWorktreeMutation) return; + const workspace = resolveWorkspace(workspaceId); + if (!workspace.laneId) return; + onLaneWorktreeMutation({ + laneId: workspace.laneId, + reason + }); + }; + const listWorkspaces = (_args: FilesListWorkspacesArgs = {}): FilesWorkspace[] => { const scopes = laneService.getFilesWorkspaces(); return scopes.map((scope) => ({ @@ -262,6 +278,12 @@ export function createFileService({ laneService }: { laneService: ReturnType isIgnoredPath(workspace.rootPath, relPath, false) }); + emitLaneMutation(args.workspaceId, "file_write"); }, createFile(args: FilesCreateFileArgs): void { @@ -327,6 +350,7 @@ export function createFileService({ laneService }: { laneService: ReturnType isIgnoredPath(workspace.rootPath, relPath, false) }); + emitLaneMutation(args.workspaceId, "file_create"); }, createDirectory(args: FilesCreateDirectoryArgs): void { @@ -334,6 +358,7 @@ export function createFileService({ laneService }: { laneService: ReturnType isIgnoredPath(workspace.rootPath, relPath, false) }); + emitLaneMutation(args.workspaceId, "file_rename"); }, deletePath(args: FilesDeleteArgs): void { @@ -369,6 +395,7 @@ export function createFileService({ laneService }: { laneService: ReturnType isIgnoredPath(workspace.rootPath, relPath, false) }); + emitLaneMutation(args.workspaceId, "file_delete"); }, async watchWorkspace(args: FilesWatchArgs, callback: (ev: FileChangeEvent) => void, senderId: number): Promise { diff --git a/apps/desktop/src/main/services/git/git.ts b/apps/desktop/src/main/services/git/git.ts index 64bbf7740..f45a82116 100644 --- a/apps/desktop/src/main/services/git/git.ts +++ b/apps/desktop/src/main/services/git/git.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import type { ConflictFileType } from "../../../shared/types"; export type GitRunOptions = { cwd: string; @@ -12,6 +13,20 @@ export type GitRunResult = { stderr: string; }; +export type GitMergeTreeConflict = { + path: string; + conflictType: ConflictFileType; + markerPreview: string; +}; + +export type GitMergeTreeResult = GitRunResult & { + mergeBase: string; + branchA: string; + branchB: string; + conflicts: GitMergeTreeConflict[]; + usedWriteTree: boolean; +}; + export async function runGit(args: string[], opts: GitRunOptions): Promise { const timeoutMs = opts.timeoutMs ?? 30_000; @@ -60,3 +75,100 @@ export async function runGitOrThrow(args: string[], opts: GitRunOptions): Promis return res.stdout; } +function normalizeConflictType(raw: string): ConflictFileType { + const value = raw.trim().toLowerCase(); + if (value.includes("rename")) return "rename"; + if (value.includes("delete")) return "delete"; + if (value.includes("add")) return "add"; + return "content"; +} + +function parseMergeTreeConflicts(output: string): GitMergeTreeConflict[] { + const lines = output.split(/\r?\n/); + const byPath = new Map(); + + const addConflict = (path: string, type: ConflictFileType, markerPreview: string) => { + const clean = path.trim(); + if (!clean.length) return; + if (byPath.has(clean)) return; + byPath.set(clean, { path: clean, conflictType: type, markerPreview }); + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const conflictMatch = line.match(/^CONFLICT \(([^)]+)\): .* in (.+)$/); + if (conflictMatch) { + const type = normalizeConflictType(conflictMatch[1] ?? ""); + const path = (conflictMatch[2] ?? "").trim(); + const markerPreview = [lines[i], lines[i + 1], lines[i + 2]].filter(Boolean).join("\n"); + addConflict(path, type, markerPreview); + continue; + } + + const autoMergingMatch = line.match(/^Auto-merging (.+)$/); + if (autoMergingMatch && (lines[i + 1] ?? "").startsWith("CONFLICT")) { + const next = lines[i + 1] ?? ""; + const typeMatch = next.match(/^CONFLICT \(([^)]+)\):/); + const type = normalizeConflictType(typeMatch?.[1] ?? ""); + const path = (autoMergingMatch[1] ?? "").trim(); + const markerPreview = [line, next, lines[i + 2]].filter(Boolean).join("\n"); + addConflict(path, type, markerPreview); + } + } + + return Array.from(byPath.values()); +} + +function shouldFallbackFromWriteTree(stderr: string): boolean { + const text = stderr.toLowerCase(); + return ( + text.includes("unable to create temporary file") || + text.includes("failure to merge") || + text.includes("unknown option") || + text.includes("usage: git merge-tree") + ); +} + +export async function runGitMergeTree(args: { + cwd: string; + mergeBase: string; + branchA: string; + branchB: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = args.timeoutMs ?? 45_000; + const writeTreeCmd = [ + "merge-tree", + "--write-tree", + "--messages", + "--merge-base", + args.mergeBase, + args.branchA, + args.branchB + ]; + + const writeTree = await runGit(writeTreeCmd, { cwd: args.cwd, timeoutMs }); + if (!(writeTree.exitCode !== 0 && shouldFallbackFromWriteTree(writeTree.stderr))) { + const combined = `${writeTree.stdout}\n${writeTree.stderr}`; + return { + ...writeTree, + mergeBase: args.mergeBase, + branchA: args.branchA, + branchB: args.branchB, + conflicts: parseMergeTreeConflicts(combined), + usedWriteTree: true + }; + } + + const fallbackCmd = ["merge-tree", args.mergeBase, args.branchA, args.branchB]; + const fallback = await runGit(fallbackCmd, { cwd: args.cwd, timeoutMs }); + const combined = `${fallback.stdout}\n${fallback.stderr}`; + return { + ...fallback, + mergeBase: args.mergeBase, + branchA: args.branchA, + branchB: args.branchB, + conflicts: parseMergeTreeConflicts(combined), + usedWriteTree: false + }; +} diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 13051f912..8f27dd3c1 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -63,7 +63,8 @@ export function createGitOperationsService({ laneService, operationService, logger, - onHeadChanged + onHeadChanged, + onWorktreeChanged }: { laneService: ReturnType; operationService: ReturnType; @@ -75,6 +76,13 @@ export function createGitOperationsService({ preHeadSha: string | null; postHeadSha: string | null; }) => void; + onWorktreeChanged?: (args: { + laneId: string; + reason: string; + operationId: string; + preHeadSha: string | null; + postHeadSha: string | null; + }) => void; }) { const runLaneOperation = async ({ laneId, @@ -112,6 +120,20 @@ export function createGitOperationsService({ postHeadSha }); + if (onWorktreeChanged) { + try { + onWorktreeChanged({ + laneId, + reason, + operationId: operation.operationId, + preHeadSha, + postHeadSha + }); + } catch { + // Never fail git operation due to callback issues. + } + } + if (preHeadSha !== postHeadSha && onHeadChanged) { try { onHeadChanged({ diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 7a18f41a1..9d5c94fd2 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1,12 +1,17 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; import { IPC } from "../../../shared/ipc"; import type { + BatchAssessmentResult, AttachLaneArgs, AppInfo, ArchiveLaneArgs, + ConflictOverlap, + ConflictStatus, CreateLaneArgs, + CreateChildLaneArgs, DeleteLaneArgs, DockLayout, + GraphPersistedState, FileChangeEvent, FileContent, FileTreeNode, @@ -35,15 +40,19 @@ import type { GitStashRefArgs, GitStashSummary, GitSyncArgs, + GetLaneConflictStatusArgs, GetDiffChangesArgs, GetFileDiffArgs, GetProcessLogTailArgs, GetTestLogTailArgs, LaneSummary, ListOperationsArgs, + ListOverlapsArgs, ListLanesArgs, ListSessionsArgs, ListTestRunsArgs, + MergeSimulationArgs, + MergeSimulationResult, OperationRecord, PackSummary, ProcessActionArgs, @@ -58,14 +67,22 @@ import type { ProjectInfo, PtyCreateArgs, PtyCreateResult, + ReparentLaneArgs, + ReparentLaneResult, RenameLaneArgs, + RestackArgs, + RestackResult, + RiskMatrixEntry, + RunConflictPredictionArgs, RunTestSuiteArgs, SessionDeltaSummary, + StackChainItem, StopTestRunArgs, TerminalSessionDetail, TerminalSessionSummary, TestRunSummary, TestSuiteDefinition, + UpdateLaneAppearanceArgs, WriteTextAtomicArgs } from "../../../shared/types"; import type { Logger } from "../logging/logger"; @@ -81,6 +98,8 @@ import type { createTestService } from "../tests/testService"; import type { createGitOperationsService } from "../git/gitOperationsService"; import type { createPackService } from "../packs/packService"; import type { createOperationService } from "../history/operationService"; +import type { createConflictService } from "../conflicts/conflictService"; +import type { createJobEngine } from "../jobs/jobEngine"; export type AppContext = { db: AdeDb; @@ -95,6 +114,8 @@ export type AppContext = { fileService: ReturnType; operationService: ReturnType; gitService: ReturnType; + conflictService: ReturnType; + jobEngine: ReturnType; packService: ReturnType; projectConfigService: ReturnType; processService: ReturnType; @@ -175,6 +196,18 @@ export function registerIpc({ ctx.logger.debug("layout.set", { key, panels: Object.keys(safe).length }); }); + ipcMain.handle(IPC.graphStateGet, async (_event, arg: { projectId: string }): Promise => { + const ctx = getCtx(); + const key = `graph_state:${arg.projectId}`; + return ctx.db.getJson(key); + }); + + ipcMain.handle(IPC.graphStateSet, async (_event, arg: { projectId: string; state: GraphPersistedState }): Promise => { + const ctx = getCtx(); + const key = `graph_state:${arg.projectId}`; + ctx.db.setJson(key, arg.state); + }); + ipcMain.handle(IPC.lanesList, async (_event, arg: ListLanesArgs): Promise => { const ctx = getCtx(); return await ctx.laneService.list(arg); @@ -182,7 +215,12 @@ export function registerIpc({ ipcMain.handle(IPC.lanesCreate, async (_event, arg: CreateLaneArgs): Promise => { const ctx = getCtx(); - return await ctx.laneService.create({ name: arg.name, description: arg.description }); + return await ctx.laneService.create({ name: arg.name, description: arg.description, parentLaneId: arg.parentLaneId }); + }); + + ipcMain.handle(IPC.lanesCreateChild, async (_event, arg: CreateChildLaneArgs): Promise => { + const ctx = getCtx(); + return await ctx.laneService.createChild(arg); }); ipcMain.handle(IPC.lanesAttach, async (_event, arg: AttachLaneArgs): Promise => { @@ -195,6 +233,16 @@ export function registerIpc({ ctx.laneService.rename(arg); }); + ipcMain.handle(IPC.lanesReparent, async (_event, arg: ReparentLaneArgs): Promise => { + const ctx = getCtx(); + return await ctx.laneService.reparent(arg); + }); + + ipcMain.handle(IPC.lanesUpdateAppearance, async (_event, arg: UpdateLaneAppearanceArgs): Promise => { + const ctx = getCtx(); + ctx.laneService.updateAppearance(arg); + }); + ipcMain.handle(IPC.lanesArchive, async (_event, arg: ArchiveLaneArgs): Promise => { const ctx = getCtx(); ctx.laneService.archive(arg); @@ -205,6 +253,21 @@ export function registerIpc({ await ctx.laneService.delete(arg); }); + ipcMain.handle(IPC.lanesGetStackChain, async (_event, arg: { laneId: string }): Promise => { + const ctx = getCtx(); + return await ctx.laneService.getStackChain(arg.laneId); + }); + + ipcMain.handle(IPC.lanesGetChildren, async (_event, arg: { laneId: string }): Promise => { + const ctx = getCtx(); + return await ctx.laneService.getChildren(arg.laneId); + }); + + ipcMain.handle(IPC.lanesRestack, async (_event, arg: RestackArgs): Promise => { + const ctx = getCtx(); + return await ctx.laneService.restack(arg); + }); + ipcMain.handle(IPC.lanesOpenFolder, async (_event, arg: { laneId: string }): Promise => { const ctx = getCtx(); const worktreePath = ctx.laneService.getLaneWorktreePath(arg.laneId); @@ -421,6 +484,36 @@ export function registerIpc({ return ctx.gitService.push(arg); }); + ipcMain.handle(IPC.conflictsGetLaneStatus, async (_event, arg: GetLaneConflictStatusArgs): Promise => { + const ctx = getCtx(); + return await ctx.conflictService.getLaneStatus(arg); + }); + + ipcMain.handle(IPC.conflictsListOverlaps, async (_event, arg: ListOverlapsArgs): Promise => { + const ctx = getCtx(); + return await ctx.conflictService.listOverlaps(arg); + }); + + ipcMain.handle(IPC.conflictsGetRiskMatrix, async (): Promise => { + const ctx = getCtx(); + return await ctx.conflictService.getRiskMatrix(); + }); + + ipcMain.handle(IPC.conflictsSimulateMerge, async (_event, arg: MergeSimulationArgs): Promise => { + const ctx = getCtx(); + return await ctx.conflictService.simulateMerge(arg); + }); + + ipcMain.handle(IPC.conflictsRunPrediction, async (_event, arg: RunConflictPredictionArgs = {}): Promise => { + const ctx = getCtx(); + return await ctx.conflictService.runPrediction(arg); + }); + + ipcMain.handle(IPC.conflictsGetBatchAssessment, async (): Promise => { + const ctx = getCtx(); + return await ctx.conflictService.getBatchAssessment(); + }); + ipcMain.handle(IPC.packsGetProjectPack, async (): Promise => { const ctx = getCtx(); return ctx.packService.getProjectPack(); diff --git a/apps/desktop/src/main/services/jobs/jobEngine.ts b/apps/desktop/src/main/services/jobs/jobEngine.ts index 5a9a9f058..f76973331 100644 --- a/apps/desktop/src/main/services/jobs/jobEngine.ts +++ b/apps/desktop/src/main/services/jobs/jobEngine.ts @@ -1,5 +1,6 @@ import type { Logger } from "../logging/logger"; import type { createPackService } from "../packs/packService"; +import type { createConflictService } from "../conflicts/conflictService"; type RefreshRequest = { laneId: string; @@ -15,12 +16,18 @@ type LaneQueueState = { export function createJobEngine({ logger, - packService + packService, + conflictService }: { logger: Logger; packService: ReturnType; + conflictService?: ReturnType; }) { const laneQueue = new Map(); + const dirtyLaneQueue = new Set(); + let dirtyQueueTimer: NodeJS.Timeout | null = null; + let fullConflictPredictionQueued = false; + let periodicTimer: NodeJS.Timeout | null = null; const ensureState = (laneId: string): LaneQueueState => { const existing = laneQueue.get(laneId); @@ -71,6 +78,67 @@ export function createJobEngine({ void runLaneRefresh(request.laneId); }; + const flushConflictPredictionQueue = async () => { + dirtyQueueTimer = null; + if (!conflictService) return; + + if (fullConflictPredictionQueued) { + fullConflictPredictionQueued = false; + dirtyLaneQueue.clear(); + try { + logger.info("jobs.conflicts.predict.begin", { scope: "all" }); + await conflictService.runPrediction({}); + logger.info("jobs.conflicts.predict.done", { scope: "all" }); + } catch (error) { + logger.warn("jobs.conflicts.predict.failed", { + scope: "all", + error: error instanceof Error ? error.message : String(error) + }); + } + return; + } + + const laneIds = Array.from(dirtyLaneQueue); + dirtyLaneQueue.clear(); + for (const laneId of laneIds) { + try { + logger.info("jobs.conflicts.predict.begin", { scope: "lane", laneId }); + await conflictService.runPrediction({ laneId }); + logger.info("jobs.conflicts.predict.done", { scope: "lane", laneId }); + } catch (error) { + logger.warn("jobs.conflicts.predict.failed", { + scope: "lane", + laneId, + error: error instanceof Error ? error.message : String(error) + }); + } + } + }; + + const queueConflictPrediction = (args: { laneId?: string; debounceMs?: number }) => { + if (!conflictService) return; + if (args.laneId) { + dirtyLaneQueue.add(args.laneId); + } else { + fullConflictPredictionQueued = true; + dirtyLaneQueue.clear(); + } + if (dirtyQueueTimer) clearTimeout(dirtyQueueTimer); + dirtyQueueTimer = setTimeout(() => { + void flushConflictPredictionQueue(); + }, args.debounceMs ?? 1_200); + }; + + const startPeriodicPrediction = () => { + if (!conflictService || periodicTimer) return; + periodicTimer = setInterval(() => { + queueConflictPrediction({ debounceMs: 250 }); + }, 120_000); + }; + + startPeriodicPrediction(); + queueConflictPrediction({ debounceMs: 2_000 }); + return { enqueueLaneRefresh, @@ -87,6 +155,27 @@ export function createJobEngine({ laneId: args.laneId, reason: args.reason }); + queueConflictPrediction({ laneId: args.laneId, debounceMs: 1_500 }); + }, + + onLaneDirtyChanged(args: { laneId: string; reason: string }) { + logger.debug("jobs.conflicts.queue_lane_dirty", args); + queueConflictPrediction({ laneId: args.laneId, debounceMs: 900 }); + }, + + runConflictPredictionNow(args: { laneId?: string } = {}) { + queueConflictPrediction({ laneId: args.laneId, debounceMs: 0 }); + }, + + dispose() { + if (dirtyQueueTimer) { + clearTimeout(dirtyQueueTimer); + dirtyQueueTimer = null; + } + if (periodicTimer) { + clearInterval(periodicTimer); + periodicTimer = null; + } } }; } diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index c6dd2c5e1..8f7a0f1a6 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3,7 +3,43 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import type { AdeDb } from "../state/kvDb"; import { runGit, runGitOrThrow } from "../git/git"; -import type { AttachLaneArgs, DeleteLaneArgs, LaneStatus, LaneSummary, LaneType } from "../../../shared/types"; +import type { createOperationService } from "../history/operationService"; +import type { + AttachLaneArgs, + CreateChildLaneArgs, + CreateLaneArgs, + DeleteLaneArgs, + LaneIcon, + LaneStatus, + LaneSummary, + LaneType, + ReparentLaneArgs, + ReparentLaneResult, + RestackArgs, + RestackResult, + StackChainItem, + UpdateLaneAppearanceArgs +} from "../../../shared/types"; + +type LaneRow = { + id: string; + project_id: string; + name: string; + description: string | null; + lane_type: LaneType; + base_ref: string; + branch_ref: string; + worktree_path: string; + attached_root_path: string | null; + is_edit_protected: number; + parent_lane_id: string | null; + color: string | null; + icon: string | null; + tags_json: string | null; + created_at: string; + archived_at: string | null; + status: string; +}; function slugify(input: string): string { const s = input @@ -18,19 +54,37 @@ function normAbs(p: string): string { return path.resolve(p); } -function toLaneSummary(row: { - id: string; - name: string; - description: string | null; - lane_type: LaneType; - base_ref: string; - branch_ref: string; - worktree_path: string; - attached_root_path: string | null; - is_edit_protected: number; - created_at: string; - archived_at: string | null; -}, status: LaneStatus): LaneSummary { +function parseLaneIcon(value: string | null): LaneIcon { + if (!value) return null; + if (value === "star" || value === "flag" || value === "bolt" || value === "shield" || value === "tag") { + return value; + } + return null; +} + +function parseLaneTags(raw: string | null): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean) + .slice(0, 24); + } catch { + return []; + } +} + +function toLaneSummary(args: { + row: LaneRow; + status: LaneStatus; + parentStatus: LaneStatus | null; + childCount: number; + stackDepth: number; +}): LaneSummary { + const { row, status, parentStatus, childCount, stackDepth } = args; return { id: row.id, name: row.name, @@ -40,8 +94,15 @@ function toLaneSummary(row: { branchRef: row.branch_ref, worktreePath: row.worktree_path, attachedRootPath: row.attached_root_path, + parentLaneId: row.parent_lane_id, + childCount, + stackDepth, + parentStatus, isEditProtected: row.is_edit_protected === 1, status, + color: row.color, + icon: parseLaneIcon(row.icon), + tags: parseLaneTags(row.tags_json), createdAt: row.created_at, archivedAt: row.archived_at }; @@ -56,6 +117,13 @@ async function detectBranchRef(worktreePath: string, fallback: string): Promise< return fallback; } +async function getHeadSha(worktreePath: string): Promise { + const head = await runGit(["rev-parse", "HEAD"], { cwd: worktreePath, timeoutMs: 8_000 }); + if (head.exitCode !== 0) return null; + const sha = head.stdout.trim(); + return sha.length ? sha : null; +} + async function computeLaneStatus(worktreePath: string, baseRef: string, branchRef: string): Promise { const dirtyRes = await runGit(["status", "--porcelain=v1"], { cwd: worktreePath, timeoutMs: 8_000 }); const dirty = dirtyRes.exitCode === 0 && dirtyRes.stdout.trim().length > 0; @@ -77,56 +145,89 @@ async function computeLaneStatus(worktreePath: string, baseRef: string, branchRe return { dirty, ahead, behind }; } +function computeStackDepth(args: { + laneId: string; + rowsById: Map; + memo: Map; + visiting?: Set; +}): number { + const { laneId, rowsById, memo } = args; + const visiting = args.visiting ?? new Set(); + const cached = memo.get(laneId); + if (cached != null) return cached; + if (visiting.has(laneId)) return 0; + visiting.add(laneId); + const row = rowsById.get(laneId); + let depth = 0; + if (row?.parent_lane_id) { + depth = 1 + computeStackDepth({ laneId: row.parent_lane_id, rowsById, memo, visiting }); + } + memo.set(laneId, depth); + visiting.delete(laneId); + return depth; +} + +function sortByCreatedAtAsc(rows: LaneRow[]): LaneRow[] { + return [...rows].sort((a, b) => { + const aTs = Date.parse(a.created_at); + const bTs = Date.parse(b.created_at); + if (!Number.isNaN(aTs) && !Number.isNaN(bTs) && aTs !== bTs) return aTs - bTs; + return a.name.localeCompare(b.name); + }); +} + +function collectDepthFirstIds(args: { + rootLaneId: string; + childrenByParent: Map; + includeSelf: boolean; +}): string[] { + const out: string[] = []; + const visit = (laneId: string) => { + out.push(laneId); + for (const child of args.childrenByParent.get(laneId) ?? []) { + visit(child.id); + } + }; + visit(args.rootLaneId); + return args.includeSelf ? out : out.slice(1); +} + export function createLaneService({ db, projectRoot, projectId, defaultBaseRef, - worktreesDir + worktreesDir, + operationService, + onHeadChanged }: { db: AdeDb; projectRoot: string; projectId: string; defaultBaseRef: string; worktreesDir: string; + operationService?: ReturnType; + onHeadChanged?: (args: { laneId: string; reason: string; preHeadSha: string | null; postHeadSha: string | null }) => void; }) { const getLaneRow = (laneId: string) => - db.get<{ - id: string; - name: string; - description: string | null; - lane_type: LaneType; - base_ref: string; - branch_ref: string; - worktree_path: string; - attached_root_path: string | null; - is_edit_protected: number; - created_at: string; - archived_at: string | null; - status: string; - }>("select * from lanes where id = ? limit 1", [laneId]); + db.get("select * from lanes where id = ? and project_id = ? limit 1", [laneId, projectId]); const getAllLaneRows = (includeArchived = false) => - db.all<{ - id: string; - name: string; - description: string | null; - lane_type: LaneType; - base_ref: string; - branch_ref: string; - worktree_path: string; - attached_root_path: string | null; - is_edit_protected: number; - created_at: string; - archived_at: string | null; - status: string; - }>( + db.all( includeArchived ? "select * from lanes where project_id = ? order by created_at desc" : "select * from lanes where project_id = ? and status != 'archived' order by created_at desc", [projectId] ); + const getChildrenRows = (laneId: string, includeArchived = false) => + db.all( + includeArchived + ? "select * from lanes where project_id = ? and parent_lane_id = ? order by created_at asc" + : "select * from lanes where project_id = ? and parent_lane_id = ? and status != 'archived' order by created_at asc", + [projectId, laneId] + ); + const ensureSameRepo = async (candidatePath: string) => { const resolvedProject = normAbs(projectRoot); const repoTop = (await runGitOrThrow(["rev-parse", "--show-toplevel"], { cwd: candidatePath, timeoutMs: 10_000 })).trim(); @@ -149,111 +250,530 @@ export function createLaneService({ ` insert into lanes( id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, status, created_at, archived_at + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at ) - values(?, ?, ?, ?, 'primary', ?, ?, ?, null, 1, 'active', ?, null) + values(?, ?, ?, ?, 'primary', ?, ?, ?, null, 1, null, null, null, null, 'active', ?, null) `, [laneId, projectId, "Primary", "Main repository workspace", defaultBaseRef, branchRef, projectRoot, now] ); }; + const listLanes = async ({ includeArchived = false }: { includeArchived?: boolean } = {}): Promise => { + await ensurePrimaryLane(); + + const rows = getAllLaneRows(includeArchived); + const contextRows = getAllLaneRows(true); + const activeRows = contextRows.filter((row) => row.status !== "archived"); + const rowsById = new Map(contextRows.map((row) => [row.id, row] as const)); + const depthMemo = new Map(); + const statusCache = new Map(); + const childCountMap = new Map(); + + for (const row of activeRows) { + if (!row.parent_lane_id) continue; + childCountMap.set(row.parent_lane_id, (childCountMap.get(row.parent_lane_id) ?? 0) + 1); + } + + const resolveStatus = async (laneId: string): Promise => { + const cached = statusCache.get(laneId); + if (cached) return cached; + const row = rowsById.get(laneId); + if (!row) return { dirty: false, ahead: 0, behind: 0 }; + const parent = row.parent_lane_id ? rowsById.get(row.parent_lane_id) : null; + const baseRef = parent?.branch_ref ?? row.base_ref; + const status = await computeLaneStatus(row.worktree_path, baseRef, row.branch_ref); + statusCache.set(laneId, status); + return status; + }; + + const out: LaneSummary[] = []; + for (const row of rows) { + const status = await resolveStatus(row.id); + const parentStatus = row.parent_lane_id ? await resolveStatus(row.parent_lane_id) : null; + const stackDepth = computeStackDepth({ laneId: row.id, rowsById, memo: depthMemo }); + out.push( + toLaneSummary({ + row, + status, + parentStatus, + childCount: childCountMap.get(row.id) ?? 0, + stackDepth + }) + ); + } + return out; + }; + + const createWorktreeLane = async (args: { + name: string; + description?: string; + baseRef: string; + startPoint: string; + parentLaneId: string | null; + }): Promise => { + const laneId = randomUUID(); + const now = new Date().toISOString(); + const slug = slugify(args.name); + const suffix = laneId.slice(0, 8); + const branchRef = `ade/${slug}-${suffix}`; + const worktreePath = path.join(worktreesDir, `${slug}-${suffix}`); + + await runGitOrThrow(["worktree", "add", "-b", branchRef, worktreePath, args.startPoint], { + cwd: projectRoot, + timeoutMs: 60_000 + }); + + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) + values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, null, null, null, 'active', ?, null) + `, + [laneId, projectId, args.name, args.description ?? null, args.baseRef, branchRef, worktreePath, args.parentLaneId, now] + ); + + const row = getLaneRow(laneId); + if (!row) throw new Error(`Failed to create lane: ${laneId}`); + const rowsById = new Map(getAllLaneRows(true).map((entry) => [entry.id, entry] as const)); + const status = await computeLaneStatus(worktreePath, args.baseRef, branchRef); + const parentStatus = args.parentLaneId + ? await (async () => { + const parentId = args.parentLaneId; + if (!parentId) return null; + const parent = rowsById.get(parentId); + if (!parent) return null; + const grandParent = parent.parent_lane_id ? rowsById.get(parent.parent_lane_id) : null; + return await computeLaneStatus(parent.worktree_path, grandParent?.branch_ref ?? parent.base_ref, parent.branch_ref); + })() + : null; + + return toLaneSummary({ + row, + status, + parentStatus, + childCount: 0, + stackDepth: computeStackDepth({ laneId: laneId, rowsById, memo: new Map() }) + }); + }; + + const getRowsById = (includeArchived = true): Map => + new Map(getAllLaneRows(includeArchived).map((row) => [row.id, row] as const)); + + const isDescendant = (rowsById: Map, laneId: string, possibleDescendantId: string): boolean => { + const queue = [laneId]; + const visited = new Set(); + while (queue.length > 0) { + const current = queue.shift()!; + if (visited.has(current)) continue; + visited.add(current); + if (current === possibleDescendantId) return true; + for (const row of rowsById.values()) { + if (row.parent_lane_id === current) queue.push(row.id); + } + } + return false; + }; + return { async ensurePrimaryLane(): Promise { await ensurePrimaryLane(); }, async list({ includeArchived = false }: { includeArchived?: boolean } = {}): Promise { - await ensurePrimaryLane(); - const rows = getAllLaneRows(includeArchived); - const out: LaneSummary[] = []; - for (const r of rows) { - const status = await computeLaneStatus(r.worktree_path, r.base_ref, r.branch_ref); - out.push(toLaneSummary(r, status)); + return await listLanes({ includeArchived }); + }, + + async create({ name, description, parentLaneId }: CreateLaneArgs): Promise { + if (parentLaneId) { + const parent = getLaneRow(parentLaneId); + if (!parent) throw new Error(`Parent lane not found: ${parentLaneId}`); + if (parent.status === "archived") throw new Error("Parent lane is archived"); + const parentHeadSha = await getHeadSha(parent.worktree_path); + if (!parentHeadSha) throw new Error(`Unable to resolve parent HEAD for lane ${parent.name}`); + return await createWorktreeLane({ + name, + description, + baseRef: parent.branch_ref, + startPoint: parentHeadSha, + parentLaneId: parent.id + }); } - return out; + + return await createWorktreeLane({ + name, + description, + baseRef: defaultBaseRef, + startPoint: defaultBaseRef, + parentLaneId: null + }); }, - async create({ name, description }: { name: string; description?: string }): Promise { - const laneId = randomUUID(); - const now = new Date().toISOString(); - const slug = slugify(name); - const suffix = laneId.slice(0, 8); - const branchRef = `ade/${slug}-${suffix}`; - const worktreePath = path.join(worktreesDir, `${slug}-${suffix}`); - const baseRef = defaultBaseRef; + async createChild(args: CreateChildLaneArgs): Promise { + const parent = getLaneRow(args.parentLaneId); + if (!parent) throw new Error(`Parent lane not found: ${args.parentLaneId}`); + if (parent.status === "archived") throw new Error("Parent lane is archived"); + const parentHeadSha = await getHeadSha(parent.worktree_path); + if (!parentHeadSha) throw new Error(`Unable to resolve parent HEAD for lane ${parent.name}`); + return await createWorktreeLane({ + name: args.name, + description: args.description, + baseRef: parent.branch_ref, + startPoint: parentHeadSha, + parentLaneId: parent.id + }); + }, - await runGitOrThrow(["worktree", "add", "-b", branchRef, worktreePath, baseRef], { cwd: projectRoot, timeoutMs: 60_000 }); + async getChildren(laneId: string): Promise { + const children = await listLanes({ includeArchived: false }); + return children + .filter((lane) => lane.parentLaneId === laneId) + .sort((a, b) => { + const aTs = Date.parse(a.createdAt); + const bTs = Date.parse(b.createdAt); + if (!Number.isNaN(aTs) && !Number.isNaN(bTs) && aTs !== bTs) return aTs - bTs; + return a.name.localeCompare(b.name); + }); + }, - db.run( + async getStackChain(laneId: string): Promise { + const start = getLaneRow(laneId); + if (!start) throw new Error(`Lane not found: ${laneId}`); + + let rootId = start.id; + let cursor: LaneRow | null = start; + const visited = new Set(); + while (cursor?.parent_lane_id && !visited.has(cursor.id)) { + visited.add(cursor.id); + const parent = getLaneRow(cursor.parent_lane_id); + if (!parent) break; + rootId = parent.id; + cursor = parent; + } + + const chainRows = db.all<{ + id: string; + name: string; + branch_ref: string; + parent_lane_id: string | null; + base_ref: string; + worktree_path: string; + created_at: string; + }>( ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, status, created_at, archived_at + with recursive stack as ( + select id, parent_lane_id, 0 as depth + from lanes + where id = ? and project_id = ? + union all + select l.id, l.parent_lane_id, s.depth + 1 + from lanes l + join stack s on l.parent_lane_id = s.id + where l.project_id = ? and l.status != 'archived' ) - values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, 'active', ?, null) + select l.id, l.name, l.branch_ref, l.parent_lane_id, l.base_ref, l.worktree_path, l.created_at + from stack s + join lanes l on l.id = s.id + where l.project_id = ? + order by l.created_at asc `, - [laneId, projectId, name, description ?? null, baseRef, branchRef, worktreePath, now] + [rootId, projectId, projectId, projectId] ); - const status = await computeLaneStatus(worktreePath, baseRef, branchRef); + if (chainRows.length === 0) return []; + const rowsById = new Map(chainRows.map((row) => [row.id, row] as const)); + const childrenByParent = new Map(); + for (const row of chainRows) { + if (!row.parent_lane_id) continue; + const arr = childrenByParent.get(row.parent_lane_id) ?? []; + const laneRow = getLaneRow(row.id); + if (!laneRow) continue; + arr.push(laneRow); + childrenByParent.set(row.parent_lane_id, arr); + } + for (const [parentId, children] of childrenByParent.entries()) { + childrenByParent.set(parentId, sortByCreatedAtAsc(children)); + } + + const statusCache = new Map(); + const resolveStatus = async (row: { + id: string; + parent_lane_id: string | null; + base_ref: string; + worktree_path: string; + branch_ref: string; + }): Promise => { + const cached = statusCache.get(row.id); + if (cached) return cached; + const parent = row.parent_lane_id ? rowsById.get(row.parent_lane_id) : null; + const status = await computeLaneStatus(row.worktree_path, parent?.branch_ref ?? row.base_ref, row.branch_ref); + statusCache.set(row.id, status); + return status; + }; + + const out: StackChainItem[] = []; + const visit = async (id: string, depth: number): Promise => { + const row = rowsById.get(id); + if (!row) return; + out.push({ + laneId: row.id, + laneName: row.name, + branchRef: row.branch_ref, + depth, + parentLaneId: row.parent_lane_id, + status: await resolveStatus(row) + }); + for (const child of childrenByParent.get(id) ?? []) { + await visit(child.id, depth + 1); + } + }; + + await visit(rootId, 0); + return out; + }, + + async restack(args: RestackArgs): Promise { + const recursive = args.recursive ?? true; + const target = getLaneRow(args.laneId); + if (!target) throw new Error(`Lane not found: ${args.laneId}`); + if (!target.parent_lane_id) { + return { + restackedLanes: [], + failedLaneId: null, + error: "Lane has no parent; nothing to restack." + }; + } + + const activeRows = getAllLaneRows(false); + const rowsById = new Map(activeRows.map((row) => [row.id, row] as const)); + const childrenByParent = new Map(); + for (const row of activeRows) { + if (!row.parent_lane_id) continue; + const arr = childrenByParent.get(row.parent_lane_id) ?? []; + arr.push(row); + childrenByParent.set(row.parent_lane_id, arr); + } + for (const [parentId, children] of childrenByParent.entries()) { + childrenByParent.set(parentId, sortByCreatedAtAsc(children)); + } + + const restackOrder = recursive + ? collectDepthFirstIds({ rootLaneId: target.id, childrenByParent, includeSelf: true }) + : [target.id]; + + const restackedLanes: string[] = []; + for (const laneId of restackOrder) { + const lane = rowsById.get(laneId) ?? getLaneRow(laneId); + if (!lane?.parent_lane_id) continue; + const parent = rowsById.get(lane.parent_lane_id) ?? getLaneRow(lane.parent_lane_id); + if (!parent) { + return { + restackedLanes, + failedLaneId: lane.id, + error: `Parent lane not found for ${lane.name}` + }; + } + + const parentHead = await getHeadSha(parent.worktree_path); + if (!parentHead) { + return { + restackedLanes, + failedLaneId: lane.id, + error: `Unable to resolve parent HEAD for ${parent.name}` + }; + } + + const preHeadSha = await getHeadSha(lane.worktree_path); + const operation = operationService?.start({ + laneId: lane.id, + kind: "lane_restack", + preHeadSha, + metadata: { + reason: "restack", + parentLaneId: parent.id, + parentBranchRef: parent.branch_ref, + parentHeadSha: parentHead, + recursive + } + }); + + try { + await runGitOrThrow(["rebase", parentHead], { cwd: lane.worktree_path, timeoutMs: 120_000 }); + const postHeadSha = await getHeadSha(lane.worktree_path); + if (operation?.operationId) { + operationService?.finish({ + operationId: operation.operationId, + status: "succeeded", + postHeadSha + }); + } + if (preHeadSha !== postHeadSha && onHeadChanged) { + try { + onHeadChanged({ + laneId: lane.id, + reason: "restack", + preHeadSha, + postHeadSha + }); + } catch { + // Avoid surfacing callback failures to restack callers. + } + } + restackedLanes.push(lane.id); + } catch (error) { + const postHeadSha = await getHeadSha(lane.worktree_path); + const message = error instanceof Error ? error.message : String(error); + if (operation?.operationId) { + operationService?.finish({ + operationId: operation.operationId, + status: "failed", + postHeadSha, + metadataPatch: { error: message } + }); + } + return { + restackedLanes, + failedLaneId: lane.id, + error: message + }; + } + } + return { - id: laneId, - name, - description: description ?? null, - laneType: "worktree", - baseRef, - branchRef, - worktreePath, - attachedRootPath: null, - isEditProtected: false, - status, - createdAt: now, - archivedAt: null + restackedLanes, + failedLaneId: null, + error: null }; }, - async attach(args: AttachLaneArgs): Promise { - const attachedPath = normAbs(args.attachedPath); - if (!fs.existsSync(attachedPath) || !fs.statSync(attachedPath).isDirectory()) { - throw new Error("Attached lane path must be an existing directory"); + async reparent({ laneId, newParentLaneId }: ReparentLaneArgs): Promise { + const lane = getLaneRow(laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + if (lane.lane_type === "primary") throw new Error("Primary lane cannot be reparented"); + + const newParent = getLaneRow(newParentLaneId); + if (!newParent) throw new Error(`Parent lane not found: ${newParentLaneId}`); + if (newParent.status === "archived") throw new Error("Parent lane is archived"); + if (lane.id === newParent.id) throw new Error("Cannot reparent lane to itself"); + + const rowsById = getRowsById(true); + if (isDescendant(rowsById, lane.id, newParent.id)) { + throw new Error("Cannot reparent lane under one of its descendants"); } - await ensureSameRepo(attachedPath); - const laneId = randomUUID(); - const now = new Date().toISOString(); - const branchRef = await detectBranchRef(attachedPath, defaultBaseRef); - const baseRef = defaultBaseRef; + const previousParentLaneId = lane.parent_lane_id; + const previousBaseRef = lane.base_ref; + const newBaseRef = newParent.branch_ref; + const preHeadSha = await getHeadSha(lane.worktree_path); + const newParentHead = await getHeadSha(newParent.worktree_path); + if (!newParentHead) throw new Error(`Unable to resolve parent HEAD for lane ${newParent.name}`); + + const operation = operationService?.start({ + laneId: lane.id, + kind: "lane_reparent", + preHeadSha, + metadata: { + previousParentLaneId, + newParentLaneId: newParent.id, + previousBaseRef, + newBaseRef, + parentHeadSha: newParentHead + } + }); db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, status, created_at, archived_at - ) - values(?, ?, ?, ?, 'attached', ?, ?, ?, ?, 0, 'active', ?, null) - `, - [laneId, projectId, args.name, args.description ?? null, baseRef, branchRef, attachedPath, attachedPath, now] + "update lanes set parent_lane_id = ?, base_ref = ? where id = ? and project_id = ?", + [newParent.id, newBaseRef, lane.id, projectId] ); - const status = await computeLaneStatus(attachedPath, baseRef, branchRef); + try { + await runGitOrThrow(["rebase", newParentHead], { cwd: lane.worktree_path, timeoutMs: 120_000 }); + } catch (error) { + try { + await runGit(["rebase", "--abort"], { cwd: lane.worktree_path, timeoutMs: 20_000 }); + } catch { + // ignore + } + db.run( + "update lanes set parent_lane_id = ?, base_ref = ? where id = ? and project_id = ?", + [previousParentLaneId, previousBaseRef, lane.id, projectId] + ); + const message = error instanceof Error ? error.message : String(error); + if (operation?.operationId) { + const postHeadSha = await getHeadSha(lane.worktree_path); + operationService?.finish({ + operationId: operation.operationId, + status: "failed", + postHeadSha, + metadataPatch: { error: message } + }); + } + throw new Error(message); + } + + const postHeadSha = await getHeadSha(lane.worktree_path); + if (operation?.operationId) { + operationService?.finish({ + operationId: operation.operationId, + status: "succeeded", + postHeadSha + }); + } + if (preHeadSha !== postHeadSha && onHeadChanged) { + try { + onHeadChanged({ + laneId: lane.id, + reason: "reparent", + preHeadSha, + postHeadSha + }); + } catch { + // ignore callback failures + } + } + return { - id: laneId, - name: args.name, - description: args.description ?? null, - laneType: "attached", - baseRef, - branchRef, - worktreePath: attachedPath, - attachedRootPath: attachedPath, - isEditProtected: false, - status, - createdAt: now, - archivedAt: null + laneId: lane.id, + previousParentLaneId, + newParentLaneId: newParent.id, + previousBaseRef, + newBaseRef, + preHeadSha, + postHeadSha }; }, rename({ laneId, name }: { laneId: string; name: string }): void { - db.run("update lanes set name = ? where id = ?", [name, laneId]); + db.run("update lanes set name = ? where id = ? and project_id = ?", [name, laneId, projectId]); + }, + + updateAppearance({ laneId, color, icon, tags }: UpdateLaneAppearanceArgs): void { + const lane = getLaneRow(laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + const normalizedTags = tags == null + ? parseLaneTags(lane.tags_json) + : tags + .map((entry) => entry.trim()) + .filter(Boolean) + .slice(0, 24); + const normalizedColor = color === undefined ? lane.color : color; + const normalizedIcon = icon === undefined ? parseLaneIcon(lane.icon) : icon; + + db.run( + ` + update lanes + set color = ?, icon = ?, tags_json = ? + where id = ? and project_id = ? + `, + [ + normalizedColor ?? null, + normalizedIcon ?? null, + JSON.stringify(normalizedTags), + laneId, + projectId + ] + ); }, archive({ laneId }: { laneId: string }): void { @@ -263,7 +783,7 @@ export function createLaneService({ throw new Error("Primary lane cannot be archived"); } const now = new Date().toISOString(); - db.run("update lanes set status = 'archived', archived_at = ? where id = ?", [now, laneId]); + db.run("update lanes set status = 'archived', archived_at = ? where id = ? and project_id = ?", [now, laneId, projectId]); }, async delete({ @@ -279,6 +799,11 @@ export function createLaneService({ throw new Error("Primary lane cannot be deleted"); } + const childRows = getChildrenRows(laneId, false); + if (childRows.length > 0) { + throw new Error("Cannot delete a lane with active child lanes. Delete or restack/archive children first."); + } + if (row.lane_type === "worktree" && row.worktree_path && fs.existsSync(row.worktree_path)) { const dirtyRes = await runGit(["status", "--porcelain=v1"], { cwd: row.worktree_path, timeoutMs: 8_000 }); const dirty = dirtyRes.exitCode === 0 && dirtyRes.stdout.trim().length > 0; @@ -308,7 +833,6 @@ export function createLaneService({ if (remoteCheck.exitCode !== 0) { throw new Error(`Remote '${remote}' is not configured for this repository`); } - // Branch may already be deleted on remote; treat that as a no-op. const remoteRefCheck = await runGit(["ls-remote", "--heads", remote, row.branch_ref], { cwd: projectRoot, timeoutMs: 12_000 @@ -325,6 +849,7 @@ export function createLaneService({ // ignore pack folder cleanup failures } + db.run("update lanes set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from session_deltas where lane_id = ?", [laneId]); db.run("delete from terminal_sessions where lane_id = ?", [laneId]); db.run("delete from operations where lane_id = ?", [laneId]); @@ -332,7 +857,7 @@ export function createLaneService({ db.run("delete from process_runtime where lane_id = ?", [laneId]); db.run("delete from process_runs where lane_id = ?", [laneId]); db.run("delete from test_runs where lane_id = ?", [laneId]); - db.run("delete from lanes where id = ?", [laneId]); + db.run("delete from lanes where id = ? and project_id = ?", [laneId, projectId]); }, getLaneWorktreePath(laneId: string): string { @@ -384,6 +909,41 @@ export function createLaneService({ rootPath: row.worktree_path, isReadOnlyByDefault: row.is_edit_protected === 1 }; + }, + + async attach(args: AttachLaneArgs): Promise { + const attachedPath = normAbs(args.attachedPath); + if (!fs.existsSync(attachedPath) || !fs.statSync(attachedPath).isDirectory()) { + throw new Error("Attached lane path must be an existing directory"); + } + await ensureSameRepo(attachedPath); + + const laneId = randomUUID(); + const now = new Date().toISOString(); + const branchRef = await detectBranchRef(attachedPath, defaultBaseRef); + const baseRef = defaultBaseRef; + + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) + values(?, ?, ?, ?, 'attached', ?, ?, ?, ?, 0, null, null, null, null, 'active', ?, null) + `, + [laneId, projectId, args.name, args.description ?? null, baseRef, branchRef, attachedPath, attachedPath, now] + ); + + const row = getLaneRow(laneId); + if (!row) throw new Error(`Failed to attach lane: ${laneId}`); + const status = await computeLaneStatus(attachedPath, baseRef, branchRef); + return toLaneSummary({ + row, + status, + parentStatus: null, + childCount: 0, + stackDepth: 0 + }); } }; } diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index 12371a46a..7ac28331c 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -6,6 +6,8 @@ import { spawn, type ChildProcessByStdio } from "node:child_process"; import type { Readable } from "node:stream"; import type { EffectiveProjectConfig, + LaneOverlayOverrides, + LaneSummary, ProcessActionArgs, ProcessDefinition, ProcessEvent, @@ -20,6 +22,7 @@ import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "../lanes/laneService"; +import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; type ManagedTerminationReason = "stopped" | "killed" | "crashed" | "restart"; @@ -310,6 +313,25 @@ export function createProcessService({ } }; + const getLaneSummary = async (laneId: string): Promise => { + const lanes = await laneService.list({ includeArchived: false }); + const lane = lanes.find((entry) => entry.id === laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + return lane; + }; + + const getLaneOverlay = async (laneId: string, config: EffectiveProjectConfig): Promise => { + const lane = await getLaneSummary(laneId); + return matchLaneOverlayPolicies(lane, config.laneOverlayPolicies); + }; + + const applyProcessFilter = (processIds: string[], overlay: LaneOverlayOverrides): string[] => { + const allowed = overlay.processIds; + if (!allowed || allowed.length === 0) return processIds; + const allowedSet = new Set(allowed); + return processIds.filter((id) => allowedSet.has(id)); + }; + const markReadinessReady = (entry: ManagedProcessEntry) => { clearReadinessTimers(entry); entry.runtime.readiness = "ready"; @@ -451,7 +473,7 @@ export function createProcessService({ const startByDefinition = async ( laneId: string, definition: ProcessDefinition, - opts: { skipTrust?: boolean } = {} + opts: { skipTrust?: boolean; overlay?: LaneOverlayOverrides } = {} ): Promise => { if (!opts.skipTrust) projectConfigService.getExecutableConfig(); const entry = ensureEntry(laneId, definition.id, definition); @@ -460,7 +482,13 @@ export function createProcessService({ if (!definition.command.length) throw new Error(`Process '${definition.id}' has an empty command`); const laneRoot = laneService.getLaneWorktreePath(laneId); - const cwd = path.isAbsolute(definition.cwd) ? definition.cwd : path.join(laneRoot, definition.cwd); + const configuredCwd = opts.overlay?.cwd?.trim() ? opts.overlay.cwd : definition.cwd; + const cwd = path.isAbsolute(configuredCwd) ? configuredCwd : path.join(laneRoot, configuredCwd); + const env = { + ...process.env, + ...definition.env, + ...(opts.overlay?.env ?? {}) + }; fs.mkdirSync(path.dirname(processLogPath(laneId, definition.id)), { recursive: true }); const startedAt = nowIso(); @@ -473,7 +501,7 @@ export function createProcessService({ try { child = spawn(definition.command[0]!, definition.command.slice(1), { cwd, - env: { ...process.env, ...definition.env }, + env, shell: false, stdio: ["ignore", "pipe", "pipe"] }); @@ -526,10 +554,15 @@ export function createProcessService({ const startById = async (laneId: string, processId: string, opts: { skipTrust?: boolean } = {}) => { const config = opts.skipTrust ? projectConfigService.getEffective() : projectConfigService.getExecutableConfig(); + const overlay = await getLaneOverlay(laneId, config); ensureEntriesForLane(laneId, config); + const allowedIds = applyProcessFilter(config.processes.map((proc) => proc.id), overlay); + if (!allowedIds.includes(processId)) { + throw new Error(`Process '${processId}' is disabled by lane overlay policy for this lane`); + } const definition = config.processes.find((p) => p.id === processId); if (!definition) throw new Error(`Process not found: ${processId}`); - return await startByDefinition(laneId, definition, opts); + return await startByDefinition(laneId, definition, { ...opts, overlay }); }; const stopById = async (laneId: string, processId: string, intent: ManagedTerminationReason, force: boolean): Promise => { @@ -573,24 +606,26 @@ export function createProcessService({ const runStartSet = async (laneId: string, processIds: string[], startOrder: StackStartOrder): Promise => { const config = projectConfigService.getExecutableConfig(); + const overlay = await getLaneOverlay(laneId, config); ensureEntriesForLane(laneId, config); const byId = new Map(config.processes.map((p) => [p.id, p] as const)); - const known = processIds.filter((id) => byId.has(id)); + const known = applyProcessFilter(processIds.filter((id) => byId.has(id)), overlay); const ordered = startOrder === "dependency" ? resolveDependencyOrder(known, byId) : sortByDefinitions(known, byId); if (startOrder === "dependency") { for (const id of ordered) { - await startByDefinition(laneId, byId.get(id)!, {}); + await startByDefinition(laneId, byId.get(id)!, { overlay }); } return; } - await Promise.all(ordered.map((id) => startByDefinition(laneId, byId.get(id)!, {}))); + await Promise.all(ordered.map((id) => startByDefinition(laneId, byId.get(id)!, { overlay }))); }; const runStopSet = async (laneId: string, processIds: string[], startOrder: StackStartOrder): Promise => { const config = projectConfigService.get(); + const overlay = await getLaneOverlay(laneId, config.effective); ensureEntriesForLane(laneId, config.effective); const byId = new Map(config.effective.processes.map((p) => [p.id, p] as const)); - const known = processIds.filter((id) => byId.has(id)); + const known = applyProcessFilter(processIds.filter((id) => byId.has(id)), overlay); const ordered = startOrder === "dependency" ? resolveDependencyOrder(known, byId).reverse() : sortByDefinitions(known, byId).reverse(); await Promise.all(ordered.map((id) => stopById(laneId, id, "stopped", false).catch(() => {}))); }; @@ -699,4 +734,3 @@ export function createProcessService({ } }; } - diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 655e59ac9..ff5fde298 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -20,12 +20,21 @@ type PtyEntry = { disposed: boolean; }; -function resolveShell(): { file: string; args: string[] } { +type ShellSpec = { file: string; args: string[] }; + +function resolveShellCandidates(): ShellSpec[] { if (process.platform === "win32") { - return { file: "powershell.exe", args: [] }; + return [ + { file: "powershell.exe", args: [] }, + { file: "cmd.exe", args: [] } + ]; } - const shell = process.env.SHELL && process.env.SHELL.trim().length ? process.env.SHELL : "/bin/zsh"; - return { file: shell, args: [] }; + const candidates: string[] = []; + const fromEnv = process.env.SHELL?.trim(); + if (fromEnv) candidates.push(fromEnv); + candidates.push("/bin/zsh", "/bin/bash", "/bin/sh"); + const uniq = Array.from(new Set(candidates.filter(Boolean))); + return uniq.map((file) => ({ file, args: [] })); } function clampDims(cols: number, rows: number): { cols: number; rows: number } { @@ -132,6 +141,10 @@ export function createPtyService({ async create(args: PtyCreateArgs): Promise { const { laneId, title } = args; const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); + const cwd = fs.existsSync(worktreePath) ? worktreePath : projectRoot; + if (cwd !== worktreePath) { + logger.warn("pty.cwd_missing_fallback", { laneId, missingCwd: worktreePath, fallbackCwd: cwd }); + } const { cols, rows } = clampDims(args.cols, args.rows); const ptyId = randomUUID(); @@ -156,18 +169,34 @@ export function createPtyService({ }) .catch(() => {}); - const { file: shellFile, args: shellArgs } = resolveShell(); + const shellCandidates = resolveShellCandidates(); let pty: IPty; + let selectedShell: ShellSpec | null = null; try { const ptyLib = loadPty(); const opts: IWindowsPtyForkOptions = { name: "xterm-256color", cols, rows, - cwd: worktreePath, + cwd, env: { ...process.env } }; - pty = ptyLib.spawn(shellFile, shellArgs, opts); + let lastErr: unknown = null; + let created: IPty | null = null; + for (const shell of shellCandidates) { + try { + created = ptyLib.spawn(shell.file, shell.args, opts); + selectedShell = shell; + break; + } catch (err) { + lastErr = err; + logger.warn("pty.spawn_retry", { ptyId, sessionId, shell: shell.file, err: String(err) }); + } + } + if (!created) { + throw lastErr ?? new Error("Unable to spawn terminal shell."); + } + pty = created; } catch (err) { logger.error("pty.spawn_failed", { ptyId, sessionId, err: String(err) }); try { @@ -203,7 +232,7 @@ export function createPtyService({ closeEntry(ptyId, exitCode ?? null); }); - logger.info("pty.create", { ptyId, sessionId, laneId, cwd: worktreePath }); + logger.info("pty.create", { ptyId, sessionId, laneId, cwd, shell: selectedShell?.file ?? "unknown" }); return { ptyId, sessionId }; }, diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 986d7baa0..cfff8b8e8 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -151,15 +151,24 @@ function migrate(db: Database) { worktree_path text not null, attached_root_path text, is_edit_protected integer not null default 0, + parent_lane_id text, + color text, + icon text, + tags_json text, status text not null, created_at text not null, archived_at text, - foreign key(project_id) references projects(id) + foreign key(project_id) references projects(id), + foreign key(parent_lane_id) references lanes(id) ) `); addColumnIfMissing(db, "lanes", "lane_type text not null default 'worktree'", "lane_type"); addColumnIfMissing(db, "lanes", "attached_root_path text", "attached_root_path"); addColumnIfMissing(db, "lanes", "is_edit_protected integer not null default 0", "is_edit_protected"); + addColumnIfMissing(db, "lanes", "parent_lane_id text", "parent_lane_id"); + addColumnIfMissing(db, "lanes", "color text", "color"); + addColumnIfMissing(db, "lanes", "icon text", "icon"); + addColumnIfMissing(db, "lanes", "tags_json text", "tags_json"); createIndexIfColumnsExist(db, "create index if not exists idx_lanes_project_id on lanes(project_id)", "lanes", ["project_id"]); createIndexIfColumnsExist( db, @@ -167,6 +176,12 @@ function migrate(db: Database) { "lanes", ["project_id", "lane_type"] ); + createIndexIfColumnsExist( + db, + "create index if not exists idx_lanes_project_parent on lanes(project_id, parent_lane_id)", + "lanes", + ["project_id", "parent_lane_id"] + ); db.run(` create table if not exists terminal_sessions ( @@ -449,6 +464,44 @@ function migrate(db: Database) { "session_deltas", ["project_id", "started_at"] ); + + // Phase 5 conflict radar predictions. + db.run(` + create table if not exists conflict_predictions ( + id text primary key, + project_id text not null, + lane_a_id text not null, + lane_b_id text, + status text not null, + conflicting_files_json text, + overlap_files_json text, + lane_a_sha text, + lane_b_sha text, + predicted_at text not null, + expires_at text, + foreign key(project_id) references projects(id), + foreign key(lane_a_id) references lanes(id), + foreign key(lane_b_id) references lanes(id) + ) + `); + createIndexIfColumnsExist( + db, + "create index if not exists idx_cp_lane_a on conflict_predictions(lane_a_id)", + "conflict_predictions", + ["lane_a_id"] + ); + createIndexIfColumnsExist( + db, + "create index if not exists idx_cp_lane_b on conflict_predictions(lane_b_id)", + "conflict_predictions", + ["lane_b_id"] + ); + createIndexIfColumnsExist( + db, + "create index if not exists idx_cp_predicted_at on conflict_predictions(predicted_at)", + "conflict_predictions", + ["predicted_at"] + ); } export async function openKvDb(dbPath: string, logger: Logger): Promise { diff --git a/apps/desktop/src/main/services/tests/testService.ts b/apps/desktop/src/main/services/tests/testService.ts index a9c311e47..5faa91fc2 100644 --- a/apps/desktop/src/main/services/tests/testService.ts +++ b/apps/desktop/src/main/services/tests/testService.ts @@ -6,6 +6,8 @@ import type { Readable } from "node:stream"; import type { EffectiveProjectConfig, GetTestLogTailArgs, + LaneOverlayOverrides, + LaneSummary, ListTestRunsArgs, RunTestSuiteArgs, StopTestRunArgs, @@ -18,6 +20,7 @@ import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "../lanes/laneService"; +import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; type ActiveRunEntry = { laneId: string; @@ -110,6 +113,25 @@ export function createTestService({ const getSuiteMap = (config: EffectiveProjectConfig) => new Map(config.testSuites.map((s) => [s.id, s] as const)); + const getLaneSummary = async (laneId: string): Promise => { + const lanes = await laneService.list({ includeArchived: false }); + const lane = lanes.find((entry) => entry.id === laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + return lane; + }; + + const getLaneOverlay = async (laneId: string, config: EffectiveProjectConfig): Promise => { + const lane = await getLaneSummary(laneId); + return matchLaneOverlayPolicies(lane, config.laneOverlayPolicies); + }; + + const applySuiteFilter = (suiteIds: string[], overlay: LaneOverlayOverrides): string[] => { + const allowed = overlay.testSuiteIds; + if (!allowed || allowed.length === 0) return suiteIds; + const allowedSet = new Set(allowed); + return suiteIds.filter((id) => allowedSet.has(id)); + }; + const emitRun = (run: TestRunSummary) => broadcastEvent({ type: "run", run }); const emitLog = (runId: string, suiteId: string, stream: "stdout" | "stderr", chunk: string) => broadcastEvent({ type: "log", runId, suiteId, stream, chunk, ts: nowIso() }); @@ -213,11 +235,12 @@ export function createTestService({ }); }; - const spawnSuite = (laneId: string, suite: TestSuiteDefinition): TestRunSummary => { + const spawnSuite = (laneId: string, suite: TestSuiteDefinition, overlay: LaneOverlayOverrides): TestRunSummary => { const runId = randomUUID(); const startedAt = nowIso(); const laneRoot = laneService.getLaneWorktreePath(laneId); - const cwd = path.isAbsolute(suite.cwd) ? suite.cwd : path.join(laneRoot, suite.cwd); + const configuredCwd = overlay.cwd?.trim() ? overlay.cwd : suite.cwd; + const cwd = path.isAbsolute(configuredCwd) ? configuredCwd : path.join(laneRoot, configuredCwd); if (!suite.command.length) throw new Error(`Suite '${suite.id}' has an empty command`); @@ -229,7 +252,7 @@ export function createTestService({ const child: ChildProcessByStdio = spawn(suite.command[0]!, suite.command.slice(1), { cwd, - env: { ...process.env, ...suite.env }, + env: { ...process.env, ...suite.env, ...(overlay.env ?? {}) }, shell: false, stdio: ["ignore", "pipe", "pipe"] }); @@ -316,9 +339,14 @@ export function createTestService({ return projectConfigService.get().effective.testSuites; }, - run(arg: RunTestSuiteArgs): TestRunSummary { + async run(arg: RunTestSuiteArgs): Promise { const config = projectConfigService.getExecutableConfig(); + const overlay = await getLaneOverlay(arg.laneId, config); const suiteMap = getSuiteMap(config); + const availableSuiteIds = applySuiteFilter(Array.from(suiteMap.keys()), overlay); + if (!availableSuiteIds.includes(arg.suiteId)) { + throw new Error(`Test suite '${arg.suiteId}' is disabled by lane overlay policy for this lane`); + } const suite = suiteMap.get(arg.suiteId); if (!suite) throw new Error(`Test suite not found: ${arg.suiteId}`); @@ -327,7 +355,7 @@ export function createTestService({ const summary = getRunById(existing.runId, new Map([[suite.id, suite.name]])); if (summary) return summary; } - return spawnSuite(arg.laneId, suite); + return spawnSuite(arg.laneId, suite, overlay); }, stop(arg: StopTestRunArgs): void { @@ -432,4 +460,3 @@ export function createTestService({ } }; } - diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ce81e59a2..3e927603c 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1,11 +1,17 @@ import type { + BatchAssessmentResult, AttachLaneArgs, AppInfo, ArchiveLaneArgs, + ConflictEventPayload, + ConflictOverlap, + ConflictStatus, CreateLaneArgs, + CreateChildLaneArgs, DeleteLaneArgs, DiffChanges, DockLayout, + GraphPersistedState, FileChangeEvent, FileContent, FileDiff, @@ -24,6 +30,7 @@ import type { FilesWatchArgs, FilesWorkspace, FilesWriteTextArgs, + GetLaneConflictStatusArgs, GetDiffChangesArgs, GetFileDiffArgs, GetProcessLogTailArgs, @@ -39,7 +46,10 @@ import type { GitStashRefArgs, GitStashSummary, GitSyncArgs, + ListOverlapsArgs, LaneSummary, + MergeSimulationArgs, + MergeSimulationResult, ListLanesArgs, ListOperationsArgs, ListSessionsArgs, @@ -63,14 +73,22 @@ import type { PtyExitEvent, ReadTranscriptTailArgs, RenameLaneArgs, + ReparentLaneArgs, + ReparentLaneResult, + RestackArgs, + RestackResult, + RiskMatrixEntry, RunTestSuiteArgs, + RunConflictPredictionArgs, SessionDeltaSummary, + StackChainItem, StopTestRunArgs, TerminalSessionDetail, TerminalSessionSummary, TestEvent, TestRunSummary, TestSuiteDefinition, + UpdateLaneAppearanceArgs, WriteTextAtomicArgs } from "../shared/types"; @@ -91,10 +109,16 @@ declare global { lanes: { list: (args?: ListLanesArgs) => Promise; create: (args: CreateLaneArgs) => Promise; + createChild: (args: CreateChildLaneArgs) => Promise; attach: (args: AttachLaneArgs) => Promise; rename: (args: RenameLaneArgs) => Promise; + reparent: (args: ReparentLaneArgs) => Promise; + updateAppearance: (args: UpdateLaneAppearanceArgs) => Promise; archive: (args: ArchiveLaneArgs) => Promise; delete: (args: DeleteLaneArgs) => Promise; + getStackChain: (laneId: string) => Promise; + getChildren: (laneId: string) => Promise; + restack: (args: RestackArgs) => Promise; openFolder: (args: { laneId: string }) => Promise; }; sessions: { @@ -149,6 +173,15 @@ declare global { sync: (args: GitSyncArgs) => Promise; push: (args: GitPushArgs) => Promise; }; + conflicts: { + getLaneStatus: (args: GetLaneConflictStatusArgs) => Promise; + listOverlaps: (args: ListOverlapsArgs) => Promise; + getRiskMatrix: () => Promise; + simulateMerge: (args: MergeSimulationArgs) => Promise; + runPrediction: (args?: RunConflictPredictionArgs) => Promise; + getBatchAssessment: () => Promise; + onEvent: (cb: (ev: ConflictEventPayload) => void) => () => void; + }; packs: { getProjectPack: () => Promise; getLanePack: (laneId: string) => Promise; @@ -161,6 +194,10 @@ declare global { get: (layoutId: string) => Promise; set: (layoutId: string, layout: DockLayout) => Promise; }; + graphState: { + get: (projectId: string) => Promise; + set: (projectId: string, state: GraphPersistedState) => Promise; + }; processes: { listDefinitions: () => Promise; listRuntime: (laneId: string) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 8a7c6a92f..e6548d3cc 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1,13 +1,19 @@ import { contextBridge, ipcRenderer } from "electron"; import { IPC } from "../shared/ipc"; import type { + BatchAssessmentResult, AttachLaneArgs, AppInfo, ArchiveLaneArgs, + ConflictEventPayload, + ConflictOverlap, + ConflictStatus, CreateLaneArgs, + CreateChildLaneArgs, DeleteLaneArgs, DiffChanges, DockLayout, + GraphPersistedState, FileChangeEvent, FileContent, FileDiff, @@ -38,14 +44,18 @@ import type { GitStashSummary, GitSyncArgs, GetDiffChangesArgs, + GetLaneConflictStatusArgs, GetFileDiffArgs, GetProcessLogTailArgs, GetTestLogTailArgs, LaneSummary, + ListOverlapsArgs, ListLanesArgs, ListOperationsArgs, ListSessionsArgs, ListTestRunsArgs, + MergeSimulationArgs, + MergeSimulationResult, OperationRecord, PackSummary, ProcessActionArgs, @@ -63,10 +73,18 @@ import type { PtyCreateResult, PtyDataEvent, PtyExitEvent, + RiskMatrixEntry, + RunConflictPredictionArgs, ReadTranscriptTailArgs, RenameLaneArgs, + ReparentLaneArgs, + ReparentLaneResult, + RestackArgs, + RestackResult, + UpdateLaneAppearanceArgs, RunTestSuiteArgs, SessionDeltaSummary, + StackChainItem, StopTestRunArgs, TerminalSessionDetail, TerminalSessionSummary, @@ -89,10 +107,18 @@ contextBridge.exposeInMainWorld("ade", { lanes: { list: async (args: ListLanesArgs = {}): Promise => ipcRenderer.invoke(IPC.lanesList, args), create: async (args: CreateLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesCreate, args), + createChild: async (args: CreateChildLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesCreateChild, args), attach: async (args: AttachLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesAttach, args), rename: async (args: RenameLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesRename, args), + reparent: async (args: ReparentLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesReparent, args), + updateAppearance: async (args: UpdateLaneAppearanceArgs): Promise => + ipcRenderer.invoke(IPC.lanesUpdateAppearance, args), archive: async (args: ArchiveLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesArchive, args), delete: async (args: DeleteLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesDelete, args), + getStackChain: async (laneId: string): Promise => + ipcRenderer.invoke(IPC.lanesGetStackChain, { laneId }), + getChildren: async (laneId: string): Promise => ipcRenderer.invoke(IPC.lanesGetChildren, { laneId }), + restack: async (args: RestackArgs): Promise => ipcRenderer.invoke(IPC.lanesRestack, args), openFolder: async (args: { laneId: string }): Promise => ipcRenderer.invoke(IPC.lanesOpenFolder, args) }, sessions: { @@ -169,6 +195,23 @@ contextBridge.exposeInMainWorld("ade", { sync: async (args: GitSyncArgs): Promise => ipcRenderer.invoke(IPC.gitSync, args), push: async (args: GitPushArgs): Promise => ipcRenderer.invoke(IPC.gitPush, args) }, + conflicts: { + getLaneStatus: async (args: GetLaneConflictStatusArgs): Promise => + ipcRenderer.invoke(IPC.conflictsGetLaneStatus, args), + listOverlaps: async (args: ListOverlapsArgs): Promise => + ipcRenderer.invoke(IPC.conflictsListOverlaps, args), + getRiskMatrix: async (): Promise => ipcRenderer.invoke(IPC.conflictsGetRiskMatrix), + simulateMerge: async (args: MergeSimulationArgs): Promise => + ipcRenderer.invoke(IPC.conflictsSimulateMerge, args), + runPrediction: async (args: RunConflictPredictionArgs = {}): Promise => + ipcRenderer.invoke(IPC.conflictsRunPrediction, args), + getBatchAssessment: async (): Promise => ipcRenderer.invoke(IPC.conflictsGetBatchAssessment), + onEvent: (cb: (ev: ConflictEventPayload) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: ConflictEventPayload) => cb(payload); + ipcRenderer.on(IPC.conflictsEvent, listener); + return () => ipcRenderer.removeListener(IPC.conflictsEvent, listener); + } + }, packs: { getProjectPack: async (): Promise => ipcRenderer.invoke(IPC.packsGetProjectPack), getLanePack: async (laneId: string): Promise => ipcRenderer.invoke(IPC.packsGetLanePack, { laneId }), @@ -183,6 +226,12 @@ contextBridge.exposeInMainWorld("ade", { set: async (layoutId: string, layout: DockLayout): Promise => ipcRenderer.invoke(IPC.layoutSet, { layoutId, layout }) }, + graphState: { + get: async (projectId: string): Promise => + ipcRenderer.invoke(IPC.graphStateGet, { projectId }), + set: async (projectId: string, state: GraphPersistedState): Promise => + ipcRenderer.invoke(IPC.graphStateSet, { projectId, state }) + }, processes: { listDefinitions: async (): Promise => ipcRenderer.invoke(IPC.processesListDefinitions), listRuntime: async (laneId: string): Promise => ipcRenderer.invoke(IPC.processesListRuntime, { laneId }), diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 164db7648..eb84c17b5 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -6,6 +6,7 @@ import { LanesPage } from "../lanes/LanesPage"; import { FilesPage } from "../files/FilesPage"; import { TerminalsPage } from "../terminals/TerminalsPage"; import { ConflictsPage } from "../conflicts/ConflictsPage"; +import { WorkspaceGraphPage } from "../graph/WorkspaceGraphPage"; import { PRsPage } from "../prs/PRsPage"; import { HistoryPage } from "../history/HistoryPage"; import { SettingsPage } from "./SettingsPage"; @@ -32,6 +33,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index b46e4e576..5c9c24ffa 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -65,8 +65,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { {/* Main Workspace - Canvas for Lanes/Content */} -
- {children} +
+
+ {children} +
diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index a18ad3537..dca5e4b61 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -1,6 +1,6 @@ import React from "react"; import { NavLink } from "react-router-dom"; -import { Bug, FileCode2, GitPullRequest, History, LayoutGrid, Play, Settings, TerminalSquare } from "lucide-react"; +import { Bug, FileCode2, GitPullRequest, History, LayoutGrid, Network, Play, Settings, TerminalSquare } from "lucide-react"; import { cn } from "../ui/cn"; const items = [ @@ -9,6 +9,7 @@ const items = [ { to: "/files", label: "Files", icon: FileCode2 }, { to: "/terminals", label: "Terminals", icon: TerminalSquare }, { to: "/conflicts", label: "Conflicts", icon: Bug }, + { to: "/graph", label: "Graph", icon: Network }, { to: "/prs", label: "PRs", icon: GitPullRequest }, { to: "/history", label: "History", icon: History }, { to: "/settings", label: "Settings", icon: Settings } diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index edeca4243..f9cc4acf5 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -27,6 +27,7 @@ export function TopBar({ const [createOpen, setCreateOpen] = useState(false); const [laneName, setLaneName] = useState(""); + const [parentLaneId, setParentLaneId] = useState(""); const [attachOpen, setAttachOpen] = useState(false); const [attachName, setAttachName] = useState(""); const [attachPath, setAttachPath] = useState(""); @@ -38,6 +39,7 @@ export function TopBar({ () => lanes.find((l) => l.id === selectedLaneId)?.name ?? null, [lanes, selectedLaneId] ); + const selectableParentLanes = useMemo(() => lanes, [lanes]); return (
@@ -52,7 +54,7 @@ export function TopBar({ base: {baseRef ?? "?"} - sync: idle + pull: idle jobs: 0 procs: 0 @@ -100,6 +102,21 @@ export function TopBar({ className="h-10 w-full rounded-lg border border-border bg-card/70 px-3 text-sm outline-none placeholder:text-muted-fg" autoFocus /> +
+
Parent lane (optional)
+ +
@@ -108,6 +125,7 @@ export function TopBar({ onClick={() => { setCreateOpen(false); setLaneName(""); + setParentLaneId(""); }} > Cancel @@ -117,12 +135,16 @@ export function TopBar({ disabled={!laneName.trim().length} onClick={() => { const name = laneName.trim(); - window.ade.lanes - .create({ name }) + const selectedParentLaneId = parentLaneId || null; + const createPromise = selectedParentLaneId + ? window.ade.lanes.createChild({ name, parentLaneId: selectedParentLaneId }) + : window.ade.lanes.create({ name }); + createPromise .then(async (lane) => { await refreshLanes(); setCreateOpen(false); setLaneName(""); + setParentLaneId(""); navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}`); }) .catch(() => { @@ -130,7 +152,7 @@ export function TopBar({ }); }} > - Create + {parentLaneId ? "Create child lane" : "Create"}
diff --git a/apps/desktop/src/renderer/components/conflicts/ConflictFileDiff.tsx b/apps/desktop/src/renderer/components/conflicts/ConflictFileDiff.tsx new file mode 100644 index 000000000..98c21cc33 --- /dev/null +++ b/apps/desktop/src/renderer/components/conflicts/ConflictFileDiff.tsx @@ -0,0 +1,302 @@ +import React from "react"; +import type { editor as MonacoEditor } from "monaco-editor"; +import type { MergeSimulationResult } from "../../../shared/types"; +import { useAppStore } from "../../state/appStore"; +import { extensionToLanguage } from "./extensionToLanguage"; + +let monacoInit: Promise | null = null; + +async function loadMonaco(): Promise { + if (!monacoInit) { + monacoInit = (async () => { + const EditorWorker = (await import("monaco-editor/esm/vs/editor/editor.worker?worker")).default; + const globalAny = globalThis as typeof globalThis & { + MonacoEnvironment?: { + getWorker?: (workerId: string, label: string) => Worker; + }; + }; + const existing = globalAny.MonacoEnvironment; + globalAny.MonacoEnvironment = { + ...existing, + getWorker: existing?.getWorker ?? (() => new EditorWorker()) + }; + return await import("monaco-editor"); + })(); + } + return await monacoInit; +} + +function buildDecorations( + monaco: typeof import("monaco-editor"), + model: MonacoEditor.ITextModel +): MonacoEditor.IModelDeltaDecoration[] { + const lines = model.getLinesContent(); + const decorations: MonacoEditor.IModelDeltaDecoration[] = []; + + let oursStart: number | null = null; + let theirsStart: number | null = null; + let splitLine: number | null = null; + + const decorateRange = (startLine: number, endLine: number, className: string) => { + if (startLine > endLine) return; + decorations.push({ + range: new monaco.Range(startLine, 1, endLine, 1), + options: { + isWholeLine: true, + className + } + }); + }; + + for (let i = 0; i < lines.length; i += 1) { + const lineNumber = i + 1; + const content = lines[i]!.trim(); + + if (content.startsWith("<<<<<<<")) { + oursStart = lineNumber + 1; + splitLine = null; + theirsStart = null; + decorations.push({ + range: new monaco.Range(lineNumber, 1, lineNumber, 1), + options: { + isWholeLine: true, + className: "ade-conflict-marker-line", + linesDecorationsClassName: "ade-conflict-marker-glyph" + } + }); + continue; + } + + if (content.startsWith("=======") && oursStart != null) { + splitLine = lineNumber; + decorateRange(oursStart, lineNumber - 1, "ade-conflict-ours-line"); + theirsStart = lineNumber + 1; + decorations.push({ + range: new monaco.Range(lineNumber, 1, lineNumber, 1), + options: { + isWholeLine: true, + className: "ade-conflict-marker-line", + linesDecorationsClassName: "ade-conflict-marker-glyph" + } + }); + continue; + } + + if (content.startsWith(">>>>>>>") && oursStart != null) { + if (theirsStart != null) { + decorateRange(theirsStart, lineNumber - 1, "ade-conflict-theirs-line"); + } else if (splitLine != null) { + decorateRange(splitLine + 1, lineNumber - 1, "ade-conflict-theirs-line"); + } + decorations.push({ + range: new monaco.Range(lineNumber, 1, lineNumber, 1), + options: { + isWholeLine: true, + className: "ade-conflict-marker-line", + linesDecorationsClassName: "ade-conflict-marker-glyph" + } + }); + oursStart = null; + splitLine = null; + theirsStart = null; + } + } + + return decorations; +} + +export function ConflictFileDiff({ + result, + selectedPath, + onSelectPath +}: { + result: MergeSimulationResult | null; + selectedPath: string | null; + onSelectPath: (path: string) => void; +}) { + const theme = useAppStore((s) => s.theme); + const containerRef = React.useRef(null); + const editorRef = React.useRef(null); + const modelRef = React.useRef(null); + const decorationIdsRef = React.useRef([]); + const [editorReady, setEditorReady] = React.useState(false); + const [editorFailed, setEditorFailed] = React.useState(false); + + const current = React.useMemo(() => { + if (!result || result.conflictingFiles.length === 0) return null; + return result.conflictingFiles.find((item) => item.path === selectedPath) ?? result.conflictingFiles[0]!; + }, [result, selectedPath]); + + React.useEffect(() => { + let disposed = false; + loadMonaco() + .then((monaco) => { + if (disposed || !containerRef.current) return; + const editor = monaco.editor.create(containerRef.current, { + value: "", + language: "plaintext", + readOnly: true, + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: "on", + wordWrap: "on", + scrollBeyondLastLine: false, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontSize: 12, + lineHeight: 18, + folding: true, + glyphMargin: true, + renderWhitespace: "selection" + }); + editorRef.current = editor; + setEditorReady(true); + setEditorFailed(false); + }) + .catch(() => { + if (!disposed) setEditorFailed(true); + }); + + return () => { + disposed = true; + if (editorRef.current) { + try { + editorRef.current.setModel(null); + } catch { + // ignore + } + } + if (editorRef.current) { + try { + editorRef.current.dispose(); + } catch { + // ignore + } + } + editorRef.current = null; + if (modelRef.current) { + try { + modelRef.current.dispose(); + } catch { + // ignore + } + } + modelRef.current = null; + decorationIdsRef.current = []; + }; + }, []); + + React.useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + if (!current) { + try { + editor.setModel(null); + } catch { + // ignore + } + if (modelRef.current) { + try { + modelRef.current.dispose(); + } catch { + // ignore + } + } + modelRef.current = null; + decorationIdsRef.current = []; + return; + } + + let disposed = false; + loadMonaco() + .then((monaco) => { + if (disposed || !editorRef.current) return; + if (modelRef.current) { + try { + editorRef.current.setModel(null); + } catch { + // ignore + } + try { + modelRef.current.dispose(); + } catch { + // ignore + } + } + + const content = + current.conflictMarkers?.trim().length + ? current.conflictMarkers + : `No marker preview available for ${current.path}.`; + const model = monaco.editor.createModel(content, extensionToLanguage(current.path)); + modelRef.current = model; + editorRef.current.setModel(model); + decorationIdsRef.current = model.deltaDecorations( + decorationIdsRef.current, + buildDecorations(monaco, model) + ); + }) + .catch(() => { + if (!disposed) setEditorFailed(true); + }); + + return () => { + disposed = true; + }; + }, [current]); + + React.useEffect(() => { + loadMonaco() + .then((monaco) => { + monaco.editor.setTheme(theme === "dark" ? "vs-dark" : "vs"); + }) + .catch(() => { + // ignore theme updates in fallback mode + }); + }, [theme]); + + if (!result || result.conflictingFiles.length === 0) { + return ( +
+ No conflicting files to preview. +
+ ); + } + + return ( +
+
+ {result.conflictingFiles.map((file) => { + const selected = file.path === current?.path; + return ( + + ); + })} +
+
+
+ {!editorReady && !editorFailed ? ( +
+ Loading editor… +
+ ) : null} + {editorFailed && current ? ( +
+            {current.conflictMarkers?.trim().length
+              ? current.conflictMarkers
+              : `No marker preview available for ${current.path}.`}
+          
+ ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/conflicts/ConflictSummary.tsx b/apps/desktop/src/renderer/components/conflicts/ConflictSummary.tsx new file mode 100644 index 000000000..04654e1e6 --- /dev/null +++ b/apps/desktop/src/renderer/components/conflicts/ConflictSummary.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import type { ConflictOverlap, ConflictStatus, LaneSummary } from "../../../shared/types"; + +function riskClass(risk: ConflictOverlap["riskLevel"]): string { + if (risk === "high") return "text-red-300"; + if (risk === "medium") return "text-amber-300"; + if (risk === "low") return "text-emerald-300"; + return "text-muted-fg"; +} + +export function ConflictSummary({ + lane, + status, + overlaps +}: { + lane: LaneSummary | null; + status: ConflictStatus | null; + overlaps: ConflictOverlap[]; +}) { + if (!lane) { + return
Select a lane to inspect conflicts.
; + } + + return ( +
+
+
{lane.name}
+
+ Status: {status?.status ?? "unknown"} + {" · "} + overlaps: {status?.overlappingFileCount ?? 0} + {" · "} + peer conflicts: {status?.peerConflictCount ?? 0} +
+
Last predicted: {status?.lastPredictedAt ?? "never"}
+
+ + {overlaps.length === 0 ? ( +
+ No overlaps recorded yet for this lane. +
+ ) : ( +
+ {overlaps.map((overlap) => ( +
+
+
{overlap.peerName}
+
{overlap.riskLevel}
+
+
+ {overlap.files.length === 0 ? ( +
No file overlap details.
+ ) : ( + overlap.files.map((file) => ( +
+ + {file.path} + + {file.conflictType} +
+ )) + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/conflicts/ConflictsPage.tsx b/apps/desktop/src/renderer/components/conflicts/ConflictsPage.tsx index f68f1253c..9434b70fd 100644 --- a/apps/desktop/src/renderer/components/conflicts/ConflictsPage.tsx +++ b/apps/desktop/src/renderer/components/conflicts/ConflictsPage.tsx @@ -1,7 +1,422 @@ import React from "react"; -import { EmptyState } from "../ui/EmptyState"; +import { useAppStore } from "../../state/appStore"; +import type { BatchAssessmentResult, ConflictOverlap, ConflictStatus, RiskMatrixEntry } from "../../../shared/types"; +import { Button } from "../ui/Button"; +import { Chip } from "../ui/Chip"; +import { RiskMatrix } from "./RiskMatrix"; +import { ConflictSummary } from "./ConflictSummary"; +import { MergeSimulationPanel } from "./MergeSimulationPanel"; +import { cn } from "../ui/cn"; -export function ConflictsPage() { - return ; +type ViewMode = "summary" | "matrix"; +type LaneStatusFilter = "conflict" | "at-risk" | "clean" | "unknown" | null; + +function pairKey(a: string, b: string): string { + return a < b ? `${a}::${b}` : `${b}::${a}`; +} + +function statusRank(value: ConflictStatus["status"]): number { + if (value === "conflict-active") return 5; + if (value === "conflict-predicted") return 4; + if (value === "behind-base") return 3; + if (value === "unknown") return 2; + return 1; +} + +function statusDotClass(status: ConflictStatus["status"] | null): string { + if (status === "conflict-active") return "bg-red-600"; + if (status === "conflict-predicted") return "bg-orange-500"; + if (status === "behind-base") return "bg-amber-500"; + if (status === "merge-ready") return "bg-emerald-500"; + return "bg-muted-fg"; +} + +function classifyStatus(status: ConflictStatus["status"] | undefined): Exclude { + if (status === "conflict-active" || status === "conflict-predicted") return "conflict"; + if (status === "behind-base") return "at-risk"; + if (status === "merge-ready") return "clean"; + return "unknown"; +} + +function filterLaneByStatus(status: ConflictStatus["status"] | undefined, filter: LaneStatusFilter): boolean { + if (!filter) return true; + return classifyStatus(status) === filter; } +export function ConflictsPage() { + const lanes = useAppStore((s) => s.lanes); + const refreshLanes = useAppStore((s) => s.refreshLanes); + + const [batch, setBatch] = React.useState(null); + const [overlaps, setOverlaps] = React.useState([]); + const [selectedLaneId, setSelectedLaneId] = React.useState(null); + const [selectedPair, setSelectedPair] = React.useState<{ laneAId: string; laneBId: string } | null>(null); + const [viewMode, setViewMode] = React.useState("summary"); + const [statusFilter, setStatusFilter] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [progress, setProgress] = React.useState<{ completedPairs: number; totalPairs: number } | null>(null); + const [error, setError] = React.useState(null); + const [comparisonLaneIds, setComparisonLaneIds] = React.useState([]); + const [runningSelectedCompare, setRunningSelectedCompare] = React.useState(false); + + const statusByLane = React.useMemo(() => { + const map = new Map(); + for (const status of batch?.lanes ?? []) { + map.set(status.laneId, status); + } + return map; + }, [batch]); + + const matrixByPair = React.useMemo(() => { + const map = new Map(); + for (const entry of batch?.matrix ?? []) { + map.set(pairKey(entry.laneAId, entry.laneBId), entry); + } + return map; + }, [batch]); + + const sortedLanes = React.useMemo(() => { + return [...lanes].sort((a, b) => { + const statusA = statusByLane.get(a.id)?.status ?? "unknown"; + const statusB = statusByLane.get(b.id)?.status ?? "unknown"; + const rankDelta = statusRank(statusB) - statusRank(statusA); + if (rankDelta !== 0) return rankDelta; + return a.name.localeCompare(b.name); + }); + }, [lanes, statusByLane]); + + const laneSummaryCounts = React.useMemo(() => { + const counts: Record, number> = { + conflict: 0, + "at-risk": 0, + clean: 0, + unknown: 0 + }; + for (const lane of sortedLanes) { + counts[classifyStatus(statusByLane.get(lane.id)?.status)] += 1; + } + return counts; + }, [sortedLanes, statusByLane]); + + const filteredLanes = React.useMemo( + () => sortedLanes.filter((lane) => filterLaneByStatus(statusByLane.get(lane.id)?.status, statusFilter)), + [sortedLanes, statusByLane, statusFilter] + ); + + const selectedLane = React.useMemo( + () => lanes.find((lane) => lane.id === selectedLaneId) ?? null, + [lanes, selectedLaneId] + ); + + const selectedStatus = selectedLaneId ? statusByLane.get(selectedLaneId) ?? null : null; + + const selectedPairEntry = React.useMemo(() => { + if (!selectedPair) return null; + return matrixByPair.get(pairKey(selectedPair.laneAId, selectedPair.laneBId)) ?? null; + }, [matrixByPair, selectedPair]); + + const loadBatch = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const [nextBatch] = await Promise.all([ + window.ade.conflicts.getBatchAssessment(), + lanes.length ? Promise.resolve() : refreshLanes() + ]); + setBatch(nextBatch); + setProgress(nextBatch.progress ?? null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [lanes.length, refreshLanes]); + + const loadLaneOverlaps = React.useCallback(async (laneId: string) => { + try { + const next = await window.ade.conflicts.listOverlaps({ laneId }); + setOverlaps(next); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setOverlaps([]); + } + }, []); + + React.useEffect(() => { + void loadBatch(); + }, [loadBatch]); + + React.useEffect(() => { + if (!selectedLaneId && sortedLanes[0]?.id) { + setSelectedLaneId(sortedLanes[0].id); + } + }, [selectedLaneId, sortedLanes]); + + React.useEffect(() => { + if (selectedLaneId && !lanes.some((lane) => lane.id === selectedLaneId)) { + setSelectedLaneId(lanes[0]?.id ?? null); + } + }, [lanes, selectedLaneId]); + + React.useEffect(() => { + if (!selectedLaneId) { + setOverlaps([]); + return; + } + void loadLaneOverlaps(selectedLaneId); + }, [selectedLaneId, loadLaneOverlaps]); + + React.useEffect(() => { + const unsubscribe = window.ade.conflicts.onEvent((event) => { + if (event.type === "prediction-progress") { + setProgress({ completedPairs: event.completedPairs, totalPairs: event.totalPairs }); + return; + } + setProgress({ completedPairs: event.completedPairs, totalPairs: event.totalPairs }); + void loadBatch(); + if (selectedLaneId && event.laneIds.includes(selectedLaneId)) { + void loadLaneOverlaps(selectedLaneId); + } + }); + return unsubscribe; + }, [loadBatch, loadLaneOverlaps, selectedLaneId]); + + const toggleStatusFilter = (value: Exclude) => { + setStatusFilter((prev) => (prev === value ? null : value)); + }; + + React.useEffect(() => { + if (!batch?.truncated) return; + const allowedLaneIds = new Set(lanes.map((lane) => lane.id)); + setComparisonLaneIds((prev) => { + const preserved = prev.filter((laneId) => allowedLaneIds.has(laneId)); + if (preserved.length > 0) { + return preserved.slice(0, batch.maxAutoLanes ?? 15); + } + const fallback = (batch.comparedLaneIds ?? lanes.map((lane) => lane.id)) + .filter((laneId) => allowedLaneIds.has(laneId)) + .slice(0, batch.maxAutoLanes ?? 15); + return fallback; + }); + }, [batch?.truncated, batch?.comparedLaneIds, batch?.maxAutoLanes, lanes]); + + const toggleComparisonLane = (laneId: string) => { + const max = batch?.maxAutoLanes ?? 15; + setComparisonLaneIds((prev) => { + if (prev.includes(laneId)) return prev.filter((entry) => entry !== laneId); + if (prev.length >= max) return prev; + return [...prev, laneId]; + }); + }; + + const runSelectedComparison = async () => { + if (comparisonLaneIds.length < 2) { + setError("Select at least 2 lanes to run pairwise risk comparison."); + return; + } + setRunningSelectedCompare(true); + setError(null); + try { + const next = await window.ade.conflicts.runPrediction({ laneIds: comparisonLaneIds }); + setBatch(next); + setProgress(next.progress ?? null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRunningSelectedCompare(false); + } + }; + + return ( +
+
+
Conflict Radar
+
+ lanes: {lanes.length} · conflicts: {batch?.lanes.filter((entry) => entry.status === "conflict-predicted" || entry.status === "conflict-active").length ?? 0} +
+
+ + + + +
+
+ + {error ?
{error}
: null} + + {batch?.truncated ? ( +
+
+ Too many lanes for automatic risk assessment. Showing {batch.comparedLaneIds?.length ?? batch.maxAutoLanes ?? 15} of {batch.totalLanes ?? lanes.length} lanes. +
+
+ {lanes.map((lane) => { + const checked = comparisonLaneIds.includes(lane.id); + return ( + + ); + })} +
+
+ + selected {comparisonLaneIds.length}/{batch.maxAutoLanes ?? 15} + + +
+
+ ) : null} + +
+ + +
+ {viewMode === "matrix" ? ( +
+ { + setSelectedPair(pair); + setViewMode("matrix"); + setSelectedLaneId(pair.laneAId); + }} + /> + {selectedPairEntry ? ( +
+
+ Pair: {lanes.find((lane) => lane.id === selectedPairEntry.laneAId)?.name ?? selectedPairEntry.laneAId} + {" vs "} + {lanes.find((lane) => lane.id === selectedPairEntry.laneBId)?.name ?? selectedPairEntry.laneBId} +
+
+ risk: {selectedPairEntry.riskLevel} · overlap files: {selectedPairEntry.overlapCount} · has conflict: {selectedPairEntry.hasConflict ? "yes" : "no"} +
+
+ ) : null} +
+ ) : ( +
+ + +
+ )} +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/conflicts/MergeSimulationPanel.tsx b/apps/desktop/src/renderer/components/conflicts/MergeSimulationPanel.tsx new file mode 100644 index 000000000..1f00be647 --- /dev/null +++ b/apps/desktop/src/renderer/components/conflicts/MergeSimulationPanel.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import type { LaneSummary, MergeSimulationResult } from "../../../shared/types"; +import { Button } from "../ui/Button"; +import { ConflictFileDiff } from "./ConflictFileDiff"; + +export function MergeSimulationPanel({ + lanes, + initialLaneAId, + initialLaneBId +}: { + lanes: LaneSummary[]; + initialLaneAId?: string | null; + initialLaneBId?: string | null; +}) { + const [laneAId, setLaneAId] = React.useState(initialLaneAId ?? lanes[0]?.id ?? ""); + const [laneBId, setLaneBId] = React.useState(initialLaneBId ?? ""); + const [busy, setBusy] = React.useState(false); + const [result, setResult] = React.useState(null); + const [error, setError] = React.useState(null); + const [selectedConflictPath, setSelectedConflictPath] = React.useState(null); + + React.useEffect(() => { + if (initialLaneAId) setLaneAId(initialLaneAId); + }, [initialLaneAId]); + + React.useEffect(() => { + if (initialLaneBId !== undefined && initialLaneBId !== null) { + setLaneBId(initialLaneBId); + } + }, [initialLaneBId]); + + const runSimulation = async () => { + if (!laneAId) return; + setBusy(true); + setError(null); + try { + const next = await window.ade.conflicts.simulateMerge({ + laneAId, + laneBId: laneBId || undefined + }); + setResult(next); + setSelectedConflictPath(next.conflictingFiles[0]?.path ?? null); + if (next.outcome === "error") { + setError(next.error ?? "Simulation failed"); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( +
+
Merge Simulation
+
+ + + +
+ + {error ?
{error}
: null} + + {result ? ( +
+
+
+ Outcome: {result.outcome} +
+
+ files changed: {result.diffStat.filesChanged} · +{result.diffStat.insertions} / -{result.diffStat.deletions} +
+
merged files: {result.mergedFiles.length}
+
+ +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/conflicts/RiskMatrix.tsx b/apps/desktop/src/renderer/components/conflicts/RiskMatrix.tsx new file mode 100644 index 000000000..2f0ff87e6 --- /dev/null +++ b/apps/desktop/src/renderer/components/conflicts/RiskMatrix.tsx @@ -0,0 +1,286 @@ +import React from "react"; +import { Clock3 } from "lucide-react"; +import type { BatchOverlapEntry, LaneSummary, RiskMatrixEntry } from "../../../shared/types"; +import { cn } from "../ui/cn"; +import { RiskTooltip } from "./RiskTooltip"; + +function pairKey(a: string, b: string): string { + return a < b ? `${a}::${b}` : `${b}::${a}`; +} + +function cellClasses(risk: RiskMatrixEntry["riskLevel"], selected: boolean, stale: boolean): string { + const base = selected ? "ring-2 ring-accent" : "ring-1 ring-border/70"; + const staleClass = stale ? "opacity-65" : ""; + if (risk === "high") return cn("bg-red-500/30 text-red-100", base, staleClass); + if (risk === "medium") return cn("bg-amber-500/25 text-amber-100", base, staleClass); + if (risk === "low") return cn("bg-emerald-500/20 text-emerald-100", base, staleClass); + return cn("bg-card text-muted-fg", base, staleClass); +} + +function hasSamePair(a: { laneAId: string; laneBId: string }, b: { laneAId: string; laneBId: string }): boolean { + return pairKey(a.laneAId, a.laneBId) === pairKey(b.laneAId, b.laneBId); +} + +type ChangeEffect = "increased" | "decreased"; + +function formatStaleLabel(computedAt: string | null): string { + if (!computedAt) return "Stale result. Click to refresh."; + const ts = Date.parse(computedAt); + if (Number.isNaN(ts)) return "Stale result. Click to refresh."; + const deltaMs = Math.max(0, Date.now() - ts); + const minutes = Math.max(1, Math.round(deltaMs / 60_000)); + return `Last computed ${minutes} minute${minutes === 1 ? "" : "s"} ago. Click to refresh.`; +} + +export function RiskMatrix({ + lanes, + entries, + overlaps, + selectedPair, + loading, + progress, + onSelectPair +}: { + lanes: LaneSummary[]; + entries: RiskMatrixEntry[]; + overlaps: BatchOverlapEntry[]; + selectedPair: { laneAId: string; laneBId: string } | null; + loading: boolean; + progress?: { completedPairs: number; totalPairs: number } | null; + onSelectPair: (pair: { laneAId: string; laneBId: string }) => void; +}) { + const matrix = React.useMemo(() => { + const map = new Map(); + for (const entry of entries) { + map.set(pairKey(entry.laneAId, entry.laneBId), entry); + } + return map; + }, [entries]); + + const overlapsByPair = React.useMemo(() => { + const map = new Map(); + for (const overlap of overlaps) { + map.set(pairKey(overlap.laneAId, overlap.laneBId), overlap.files); + } + return map; + }, [overlaps]); + + const [hoveredPair, setHoveredPair] = React.useState<{ laneAId: string; laneBId: string } | null>(null); + const [hoveredRect, setHoveredRect] = React.useState(null); + const hoverTimerRef = React.useRef(null); + const progressStartedAtRef = React.useRef(null); + + const [changeEffects, setChangeEffects] = React.useState>({}); + const previousLevelsRef = React.useRef>(new Map()); + const seenEntryKeysRef = React.useRef>(new Set()); + const [enteredKeys, setEnteredKeys] = React.useState>({}); + + React.useEffect(() => { + const rank = (value: RiskMatrixEntry["riskLevel"]): number => { + if (value === "high") return 4; + if (value === "medium") return 3; + if (value === "low") return 2; + return 1; + }; + + const nextPrev = new Map(); + const nextEffects: Record = {}; + for (const entry of entries) { + const key = pairKey(entry.laneAId, entry.laneBId); + const previous = previousLevelsRef.current.get(key); + if (previous && previous !== entry.riskLevel) { + nextEffects[key] = rank(entry.riskLevel) > rank(previous) ? "increased" : "decreased"; + } + nextPrev.set(key, entry.riskLevel); + } + previousLevelsRef.current = nextPrev; + if (Object.keys(nextEffects).length === 0) return; + + setChangeEffects((prev) => ({ ...prev, ...nextEffects })); + const timer = window.setTimeout(() => { + setChangeEffects((prev) => { + const merged = { ...prev }; + for (const key of Object.keys(nextEffects)) delete merged[key]; + return merged; + }); + }, 700); + return () => window.clearTimeout(timer); + }, [entries]); + + React.useEffect(() => { + const newlySeen: string[] = []; + for (const entry of entries) { + const key = pairKey(entry.laneAId, entry.laneBId); + if (seenEntryKeysRef.current.has(key)) continue; + seenEntryKeysRef.current.add(key); + newlySeen.push(key); + } + if (newlySeen.length === 0) return; + setEnteredKeys((prev) => { + const next = { ...prev }; + for (const key of newlySeen) next[key] = true; + return next; + }); + const timer = window.setTimeout(() => { + setEnteredKeys((prev) => { + const next = { ...prev }; + for (const key of newlySeen) delete next[key]; + return next; + }); + }, 420); + return () => window.clearTimeout(timer); + }, [entries]); + + React.useEffect(() => { + return () => { + if (hoverTimerRef.current != null) { + window.clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }; + }, []); + + React.useEffect(() => { + if (!loading || !progress) { + progressStartedAtRef.current = null; + return; + } + if (progress.completedPairs === 0 || progressStartedAtRef.current == null) { + progressStartedAtRef.current = Date.now(); + return; + } + if (progress.totalPairs > 0 && progress.completedPairs >= progress.totalPairs) { + progressStartedAtRef.current = null; + } + }, [loading, progress]); + + if (lanes.length === 0) { + return
No lanes to compare.
; + } + + const startHover = ( + pair: { laneAId: string; laneBId: string }, + event: React.MouseEvent + ) => { + if (hoverTimerRef.current != null) { + window.clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + const anchorRect = event.currentTarget.getBoundingClientRect(); + hoverTimerRef.current = window.setTimeout(() => { + setHoveredPair(pair); + setHoveredRect(anchorRect); + }, 200); + }; + + const stopHover = () => { + if (hoverTimerRef.current != null) { + window.clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + setHoveredPair(null); + setHoveredRect(null); + }; + + const hoveredKey = hoveredPair ? pairKey(hoveredPair.laneAId, hoveredPair.laneBId) : null; + const hoveredFiles = hoveredKey ? overlapsByPair.get(hoveredKey) ?? [] : []; + const hoveredTitle = hoveredPair + ? `${lanes.find((lane) => lane.id === hoveredPair.laneAId)?.name ?? hoveredPair.laneAId} vs ${ + lanes.find((lane) => lane.id === hoveredPair.laneBId)?.name ?? hoveredPair.laneBId + }` + : ""; + const totalCells = lanes.length * lanes.length; + const completedCells = progress?.totalPairs && progress.totalPairs > 0 + ? Math.floor((progress.completedPairs / progress.totalPairs) * totalCells) + : 0; + const etaLabel = React.useMemo(() => { + if (!progress || !progressStartedAtRef.current) return null; + if (progress.completedPairs <= 0 || progress.totalPairs <= progress.completedPairs) return null; + const elapsedMs = Date.now() - progressStartedAtRef.current; + if (elapsedMs <= 0) return null; + const perPairMs = elapsedMs / progress.completedPairs; + const remaining = progress.totalPairs - progress.completedPairs; + const remainingSec = Math.max(1, Math.round((perPairMs * remaining) / 1000)); + if (remainingSec < 60) return `~${remainingSec}s left`; + return `~${Math.ceil(remainingSec / 60)}m left`; + }, [progress, entries.length]); + + return ( +
+ {loading && progress ? ( +
+ Computing {progress.completedPairs}/{progress.totalPairs} pairs…{etaLabel ? ` ${etaLabel}` : ""} +
+ ) : null} + + + + + {lanes.map((lane) => ( + + ))} + + + + {lanes.map((rowLane, rowIndex) => ( + + + {lanes.map((colLane, colIndex) => { + const key = pairKey(rowLane.id, colLane.id); + const entry = matrix.get(key); + const isSelected = selectedPair != null && hasSamePair(selectedPair, { laneAId: rowLane.id, laneBId: colLane.id }); + const riskLevel = entry?.riskLevel ?? "none"; + const isLoadingCell = loading && !entry; + const changeEffect = changeEffects[key]; + const stale = entry?.stale ?? false; + const shouldAnimateIn = Boolean(enteredKeys[key]); + const skeletonIndex = rowIndex * lanes.length + colIndex; + const staleTitle = stale ? formatStaleLabel(entry?.computedAt ?? null) : `${rowLane.name} vs ${colLane.name}`; + const skeletonResolved = loading && !entry && progress ? skeletonIndex < completedCells : false; + + return ( + + ); + })} + + ))} + +
+ Lane + + {lane.name} +
+ {rowLane.name} + + +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/components/conflicts/RiskTooltip.tsx b/apps/desktop/src/renderer/components/conflicts/RiskTooltip.tsx new file mode 100644 index 000000000..cc347f595 --- /dev/null +++ b/apps/desktop/src/renderer/components/conflicts/RiskTooltip.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { cn } from "../ui/cn"; + +function compactFilePath(value: string): string { + const normalized = value.replace(/\\/g, "/"); + const parts = normalized.split("/").filter(Boolean); + if (parts.length <= 2) return normalized; + const file = parts[parts.length - 1]!; + const parent = parts[parts.length - 2]!; + return `${parent}/${file}`; +} + +export function RiskTooltip({ + open, + anchorRect, + files, + title +}: { + open: boolean; + anchorRect: DOMRect | null; + files: string[]; + title: string; +}) { + const ref = React.useRef(null); + const [style, setStyle] = React.useState({ left: -9999, top: -9999 }); + + React.useLayoutEffect(() => { + if (!open || !anchorRect || !ref.current) return; + const tooltipRect = ref.current.getBoundingClientRect(); + const gap = 10; + let left = anchorRect.right + gap; + let top = anchorRect.top + anchorRect.height / 2 - tooltipRect.height / 2; + + if (left + tooltipRect.width > window.innerWidth - 8) { + left = anchorRect.left - tooltipRect.width - gap; + } + if (left < 8) left = 8; + + if (top + tooltipRect.height > window.innerHeight - 8) { + top = window.innerHeight - tooltipRect.height - 8; + } + if (top < 8) top = 8; + + setStyle({ left, top }); + }, [open, anchorRect, files.length]); + + const shown = files.slice(0, 10); + const hiddenCount = Math.max(0, files.length - shown.length); + + return ( +
+
{title}
+
+ {shown.length === 0 ? ( +
No overlapping files.
+ ) : ( + shown.map((file) => ( +
+ {compactFilePath(file)} +
+ )) + )} +
+ {hiddenCount > 0 ?
+{hiddenCount} more
: null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/conflicts/extensionToLanguage.ts b/apps/desktop/src/renderer/components/conflicts/extensionToLanguage.ts new file mode 100644 index 000000000..6f4795cfd --- /dev/null +++ b/apps/desktop/src/renderer/components/conflicts/extensionToLanguage.ts @@ -0,0 +1,46 @@ +const EXTENSION_LANGUAGE_MAP: Record = { + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".json": "json", + ".css": "css", + ".scss": "scss", + ".less": "less", + ".html": "html", + ".xml": "xml", + ".md": "markdown", + ".yml": "yaml", + ".yaml": "yaml", + ".toml": "ini", + ".sh": "shell", + ".bash": "shell", + ".zsh": "shell", + ".py": "python", + ".rb": "ruby", + ".go": "go", + ".rs": "rust", + ".java": "java", + ".kt": "kotlin", + ".swift": "swift", + ".php": "php", + ".sql": "sql", + ".vue": "vue", + ".svelte": "svelte", + ".dockerfile": "dockerfile" +}; + +export function extensionToLanguage(filePath: string): string { + const lower = filePath.toLowerCase().trim(); + if (!lower.length) return "plaintext"; + + if (lower.endsWith("dockerfile")) return "dockerfile"; + + const lastDot = lower.lastIndexOf("."); + if (lastDot < 0) return "plaintext"; + const ext = lower.slice(lastDot); + return EXTENSION_LANGUAGE_MAP[ext] ?? "plaintext"; +} + diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index b6253df2c..ca408b11d 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -55,11 +55,36 @@ type ContextMenuState = { nodeType: "file" | "directory"; }; +type TextPromptState = { + title: string; + message?: string; + value: string; + placeholder?: string; + confirmLabel: string; + validate?: (value: string) => string | null; + resolve: (value: string | null) => void; +}; + +let monacoInit: Promise | null = null; + async function loadMonaco(): Promise { - if (!(globalThis as any).__adeMonacoFiles) { - (globalThis as any).__adeMonacoFiles = import("monaco-editor"); + if (!monacoInit) { + monacoInit = (async () => { + const EditorWorker = (await import("monaco-editor/esm/vs/editor/editor.worker?worker")).default; + const globalAny = globalThis as typeof globalThis & { + MonacoEnvironment?: { + getWorker?: (workerId: string, label: string) => Worker; + }; + }; + const existing = globalAny.MonacoEnvironment; + globalAny.MonacoEnvironment = { + ...existing, + getWorker: existing?.getWorker ?? (() => new EditorWorker()) + }; + return await import("monaco-editor"); + })(); } - return (globalThis as any).__adeMonacoFiles; + return await monacoInit; } function parseConflictHunks(text: string): ConflictHunk[] { @@ -206,6 +231,8 @@ export function FilesPage() { const [showSearch, setShowSearch] = useState(false); const [resolvedConflictKeys, setResolvedConflictKeys] = useState>(new Set()); + const [textPrompt, setTextPrompt] = useState(null); + const [textPromptError, setTextPromptError] = useState(null); const [contextMenu, setContextMenu] = useState(null); const [error, setError] = useState(null); @@ -214,6 +241,7 @@ export function FilesPage() { const monacoRef = useRef(null); const editorRef = useRef(null); const modelRef = useRef(null); + const modelKeyRef = useRef(null); const editorHostRef = useRef(null); const editorApplyingRef = useRef(false); const activeTabPathRef = useRef(null); @@ -228,6 +256,55 @@ export function FilesPage() { [openTabs] ); + const requestTextInput = useCallback( + (args: { + title: string; + message?: string; + defaultValue?: string; + placeholder?: string; + confirmLabel?: string; + validate?: (value: string) => string | null; + }): Promise => { + return new Promise((resolve) => { + const defaultValue = args.defaultValue ?? ""; + setTextPromptError(null); + setTextPrompt({ + title: args.title, + message: args.message, + value: defaultValue, + placeholder: args.placeholder, + confirmLabel: args.confirmLabel ?? "Confirm", + validate: args.validate, + resolve + }); + }); + }, + [] + ); + + const cancelTextPrompt = useCallback(() => { + setTextPrompt((prev) => { + if (prev) prev.resolve(null); + return null; + }); + setTextPromptError(null); + }, []); + + const submitTextPrompt = useCallback(() => { + setTextPrompt((prev) => { + if (!prev) return prev; + const trimmed = prev.value.trim(); + const validationError = prev.validate?.(trimmed) ?? null; + if (validationError) { + setTextPromptError(validationError); + return prev; + } + setTextPromptError(null); + prev.resolve(trimmed); + return null; + }); + }, []); + const switchWorkspace = useCallback((nextWorkspaceId: string) => { if (!nextWorkspaceId || nextWorkspaceId === workspaceId) return; if (hasUnsavedTabs) { @@ -401,14 +478,24 @@ export function FilesPage() { return; } if (!workspaceId) return; - const next = window.prompt("Rename path", targetPath); + const next = await requestTextInput({ + title: "Rename path", + message: "Enter the new path.", + defaultValue: targetPath, + confirmLabel: "Rename", + validate: (value) => { + if (!value) return "Path is required."; + if (value === targetPath) return "Path is unchanged."; + return null; + } + }); if (!next || next === targetPath) return; await window.ade.files.rename({ workspaceId, oldPath: targetPath, newPath: next }); setOpenTabs((prev) => prev.map((tab) => (tab.path === targetPath ? { ...tab, path: next } : tab))); if (activeTabPath === targetPath) setActiveTabPath(next); setSelectedNodePath(next); await refreshTree(); - }, [canEdit, workspaceId, activeTabPath, refreshTree]); + }, [canEdit, workspaceId, requestTextInput, activeTabPath, refreshTree]); const deletePath = useCallback(async (targetPath: string) => { if (!canEdit) { @@ -432,12 +519,18 @@ export function FilesPage() { } if (!workspaceId) return; const defaultPath = basePath ? `${basePath.replace(/\/$/, "")}/new-file.txt` : "new-file.txt"; - const next = window.prompt("New file path", defaultPath); + const next = await requestTextInput({ + title: "New file", + message: "Enter file path relative to workspace.", + defaultValue: defaultPath, + confirmLabel: "Create file", + validate: (value) => (value ? null : "File path is required.") + }); if (!next) return; await window.ade.files.createFile({ workspaceId, path: next, content: "" }); await refreshTree(); await openFile(next); - }, [canEdit, workspaceId, refreshTree, openFile]); + }, [canEdit, workspaceId, requestTextInput, refreshTree, openFile]); const createDirectoryAt = useCallback(async (basePath?: string) => { if (!canEdit) { @@ -446,11 +539,17 @@ export function FilesPage() { } if (!workspaceId) return; const defaultPath = basePath ? `${basePath.replace(/\/$/, "")}/new-folder` : "new-folder"; - const next = window.prompt("New directory path", defaultPath); + const next = await requestTextInput({ + title: "New folder", + message: "Enter folder path relative to workspace.", + defaultValue: defaultPath, + confirmLabel: "Create folder", + validate: (value) => (value ? null : "Folder path is required.") + }); if (!next) return; await window.ade.files.createDirectory({ workspaceId, path: next }); await refreshTree(); - }, [canEdit, workspaceId, refreshTree]); + }, [canEdit, workspaceId, requestTextInput, refreshTree]); useEffect(() => { window.ade.files.listWorkspaces() @@ -613,6 +712,11 @@ export function FilesPage() { return () => { disposed = true; + try { + editorRef.current?.setModel(null); + } catch { + // ignore + } try { modelRef.current?.dispose(); } catch { @@ -624,6 +728,7 @@ export function FilesPage() { // ignore } modelRef.current = null; + modelKeyRef.current = null; editorRef.current = null; }; }, [mode, canEdit]); @@ -635,20 +740,45 @@ export function FilesPage() { useEffect(() => { if (!editorRef.current || !monacoRef.current || mode !== "edit") return; + const editor = editorRef.current; if (!activeTab) { - editorRef.current.setValue(""); + try { + editor.setModel(null); + } catch { + // ignore + } + try { + modelRef.current?.dispose(); + } catch { + // ignore + } + modelRef.current = null; + modelKeyRef.current = null; return; } const monaco = monacoRef.current; + const language = activeTab.languageId || "plaintext"; + const modelKey = `${activeTab.path}::${language}`; + if (modelRef.current && modelKeyRef.current === modelKey) { + editor.updateOptions({ readOnly: !canEdit || activeTab.isBinary }); + return; + } + + try { + editor.setModel(null); + } catch { + // ignore + } try { modelRef.current?.dispose(); } catch { // ignore } - modelRef.current = monaco.editor.createModel(activeTab.content, activeTab.languageId || "plaintext"); - editorRef.current.setModel(modelRef.current); - editorRef.current.updateOptions({ readOnly: !canEdit || activeTab.isBinary }); - }, [activeTab, mode, canEdit]); + modelRef.current = monaco.editor.createModel(activeTab.content, language); + modelKeyRef.current = modelKey; + editor.setModel(modelRef.current); + editor.updateOptions({ readOnly: !canEdit || activeTab.isBinary }); + }, [activeTab?.path, activeTab?.languageId, activeTab?.isBinary, mode, canEdit]); useEffect(() => { if (!activeTab || !editorRef.current || mode !== "edit") return; @@ -657,7 +787,7 @@ export function FilesPage() { editorApplyingRef.current = true; editorRef.current.setValue(activeTab.content); editorApplyingRef.current = false; - }, [activeTab, mode]); + }, [activeTab?.path, activeTab?.content, mode]); useEffect(() => { setResolvedConflictKeys(new Set()); @@ -1057,6 +1187,46 @@ export function FilesPage() {
) : null} + + {textPrompt ? ( +
+
+
{textPrompt.title}
+ {textPrompt.message ?
{textPrompt.message}
: null} + { + const nextValue = event.target.value; + setTextPrompt((prev) => (prev ? { ...prev, value: nextValue } : prev)); + if (textPromptError) setTextPromptError(null); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelTextPrompt(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + submitTextPrompt(); + } + }} + placeholder={textPrompt.placeholder} + className="h-9 w-full rounded border border-border bg-bg px-2 text-sm outline-none" + /> + {textPromptError ?
{textPromptError}
: null} +
+ + +
+
+
+ ) : null} ); } diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx new file mode 100644 index 000000000..78386aaf7 --- /dev/null +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -0,0 +1,2738 @@ +import "@xyflow/react/dist/style.css"; +import React from "react"; +import { + Background, + BackgroundVariant, + ControlButton, + Controls, + Edge, + EdgeProps, + Handle, + MarkerType, + MiniMap, + Node, + NodeProps, + Panel, + Position, + ReactFlow, + ReactFlowProvider, + applyEdgeChanges, + applyNodeChanges, + getBezierPath, + useReactFlow +} from "@xyflow/react"; +import { AlertTriangle, ArrowUpRight, Filter, Flag, GitBranch, Layers3, Plus, Search, Shield, Sparkles, Star, Tag, Zap } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import type { + BatchAssessmentResult, + ConflictStatus, + GraphFilterState, + GraphLayoutPreset, + GraphLayoutSnapshot, + GraphPersistedState, + GraphStatusFilter, + GraphViewMode, + GitSyncMode, + LaneIcon, + LaneSummary, + MergeSimulationResult +} from "../../../shared/types"; +import { useAppStore } from "../../state/appStore"; +import { Button } from "../ui/Button"; +import { Chip } from "../ui/Chip"; +import { EmptyState } from "../ui/EmptyState"; +import { cn } from "../ui/cn"; + +type GraphNodeData = { + lane: LaneSummary; + status: ConflictStatus["status"] | "unknown"; + activeSessions: number; + collapsedChildCount: number; + dimmed: boolean; + activityBucket: "min" | "low" | "medium" | "high"; + viewMode: GraphViewMode; + lastActivityAt: string | null; + highlight: boolean; + restackFailed: boolean; + restackPulse: boolean; +}; + +type GraphEdgeData = { + edgeType: "topology" | "stack" | "risk"; + riskLevel?: "none" | "low" | "medium" | "high"; + overlapCount?: number; + stale?: boolean; + dimmed?: boolean; + highlight?: boolean; +}; + +type BatchStepStatus = "pending" | "running" | "done" | "failed" | "skipped"; +type BatchStep = { + laneId: string; + laneName: string; + status: BatchStepStatus; + error?: string; +}; + +type BatchProgress = { completedPairs: number; totalPairs: number }; + +type GraphTextPromptState = { + title: string; + message?: string; + placeholder?: string; + value: string; + confirmLabel: string; + validate?: (value: string) => string | null; + resolve: (value: string | null) => void; +}; + +const VIEW_MODES: GraphViewMode[] = ["stack", "risk", "activity", "all"]; +const ICON_OPTIONS: Array<{ key: LaneIcon; label: string; icon: React.ReactNode }> = [ + { key: null, label: "None", icon: }, + { key: "star", label: "Star", icon: }, + { key: "flag", label: "Flag", icon: }, + { key: "bolt", label: "Bolt", icon: }, + { key: "shield", label: "Shield", icon: }, + { key: "tag", label: "Tag", icon: } +]; +const COLOR_PALETTE = ["#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#2563eb", "#9333ea", "#1f2937", "#f8fafc"]; +const DEFAULT_PRESET = "__default__"; +const BATCH_OPERATION_LABELS: Record = { + restack: "Rebase", + push: "Push", + fetch: "Fetch", + archive: "Archive", + delete: "Delete", + sync: "Pull" +}; + +function batchOperationLabel(operation: string): string { + return BATCH_OPERATION_LABELS[operation] ?? operation; +} + +function edgePairKey(a: string, b: string): string { + return a < b ? `${a}::${b}` : `${b}::${a}`; +} + +function sameIdSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const setA = new Set(a); + if (setA.size !== b.length) return false; + for (const id of b) { + if (!setA.has(id)) return false; + } + return true; +} + +function toRelativeTime(iso: string | null): string { + if (!iso) return "No recent activity"; + const ts = Date.parse(iso); + if (Number.isNaN(ts)) return "No recent activity"; + const delta = Math.max(0, Date.now() - ts); + const minutes = Math.floor(delta / 60_000); + if (minutes < 1) return "active just now"; + if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? "" : "s"} ago`; +} + +function buildDefaultFilter(): GraphFilterState { + return { + status: [], + laneTypes: [], + tags: [], + hidePrimary: false, + hideAttached: false, + hideArchived: true, + rootLaneId: null, + search: "" + }; +} + +function createSnapshot(viewMode: GraphViewMode): GraphLayoutSnapshot { + return { + nodePositions: {}, + collapsedLaneIds: [], + viewMode, + filters: buildDefaultFilter(), + updatedAt: new Date().toISOString() + }; +} + +function createDefaultState(): GraphPersistedState { + const basePreset: GraphLayoutPreset = { + name: DEFAULT_PRESET, + byViewMode: { + stack: createSnapshot("stack"), + risk: createSnapshot("risk"), + activity: createSnapshot("activity"), + all: createSnapshot("all") + }, + updatedAt: new Date().toISOString() + }; + return { + presets: [basePreset], + activePreset: DEFAULT_PRESET + }; +} + +function ensureGraphState(state: GraphPersistedState | null | undefined): GraphPersistedState { + if (!state || !Array.isArray(state.presets) || state.presets.length === 0) { + return createDefaultState(); + } + const validPreset = state.presets.find((preset) => preset.name === state.activePreset) ?? state.presets[0]!; + const normalizeSnapshot = (viewMode: GraphViewMode): GraphLayoutSnapshot => { + const existing = validPreset.byViewMode?.[viewMode]; + if (!existing) return createSnapshot(viewMode); + return { + nodePositions: existing.nodePositions ?? {}, + collapsedLaneIds: existing.collapsedLaneIds ?? [], + viewMode, + filters: existing.filters ?? buildDefaultFilter(), + updatedAt: existing.updatedAt ?? new Date().toISOString() + }; + }; + return { + activePreset: validPreset.name, + presets: state.presets.map((preset) => ({ + name: preset.name, + updatedAt: preset.updatedAt ?? new Date().toISOString(), + byViewMode: { + stack: preset.byViewMode?.stack ? { ...normalizeSnapshot("stack"), ...preset.byViewMode.stack, viewMode: "stack" } : createSnapshot("stack"), + risk: preset.byViewMode?.risk ? { ...normalizeSnapshot("risk"), ...preset.byViewMode.risk, viewMode: "risk" } : createSnapshot("risk"), + activity: preset.byViewMode?.activity ? { ...normalizeSnapshot("activity"), ...preset.byViewMode.activity, viewMode: "activity" } : createSnapshot("activity"), + all: preset.byViewMode?.all ? { ...normalizeSnapshot("all"), ...preset.byViewMode.all, viewMode: "all" } : createSnapshot("all") + } + })) + }; +} + +function laneStatusGroup(status: ConflictStatus["status"] | undefined): GraphStatusFilter { + if (status === "conflict-active" || status === "conflict-predicted") return "conflict"; + if (status === "behind-base") return "at-risk"; + if (status === "merge-ready") return "clean"; + return "unknown"; +} + +function riskStrokeColor(level: GraphEdgeData["riskLevel"]): string { + if (level === "high") return "#dc2626"; + if (level === "medium") return "#f59e0b"; + if (level === "low") return "#16a34a"; + return "#6b7280"; +} + +function iconGlyph(icon: LaneIcon): React.ReactNode { + if (icon === "star") return ; + if (icon === "flag") return ; + if (icon === "bolt") return ; + if (icon === "shield") return ; + if (icon === "tag") return ; + return null; +} + +function nodeDimensions(lane: LaneSummary, bucket: GraphNodeData["activityBucket"], mode: GraphViewMode): { width: number; height: number } { + if (mode === "activity") { + if (bucket === "min") return { width: 100, height: 50 }; + if (bucket === "low") return { width: 130, height: 65 }; + if (bucket === "high") return { width: 200, height: 100 }; + return { width: 160, height: 80 }; + } + if (lane.laneType === "primary") return { width: 200, height: 100 }; + return { width: 160, height: 80 }; +} + +function buildTreeDepth(lanes: LaneSummary[]): Map { + const byId = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const cache = new Map(); + const visit = (laneId: string): number => { + const existing = cache.get(laneId); + if (existing != null) return existing; + const lane = byId.get(laneId); + if (!lane || !lane.parentLaneId || !byId.has(lane.parentLaneId)) { + cache.set(laneId, 0); + return 0; + } + const depth = visit(lane.parentLaneId) + 1; + cache.set(laneId, depth); + return depth; + }; + for (const lane of lanes) visit(lane.id); + return cache; +} + +function computeAutoLayout( + lanes: LaneSummary[], + viewMode: GraphViewMode, + activityScoreByLaneId: Record +): Record { + const positions: Record = {}; + if (lanes.length === 0) return positions; + + if (viewMode === "stack") { + const depthMap = buildTreeDepth(lanes); + const lanesByDepth = new Map(); + for (const lane of lanes) { + const depth = depthMap.get(lane.id) ?? 0; + const list = lanesByDepth.get(depth) ?? []; + list.push(lane); + lanesByDepth.set(depth, list); + } + for (const [depth, levelLanes] of lanesByDepth.entries()) { + levelLanes.sort((a, b) => a.name.localeCompare(b.name)); + levelLanes.forEach((lane, index) => { + positions[lane.id] = { x: index * 220, y: depth * 170 }; + }); + } + return positions; + } + + if (viewMode === "risk") { + const radius = Math.max(180, lanes.length * 24); + const centerX = 380; + const centerY = 260; + lanes.forEach((lane, index) => { + const angle = (index / lanes.length) * Math.PI * 2; + positions[lane.id] = { + x: centerX + Math.cos(angle) * radius, + y: centerY + Math.sin(angle) * radius + }; + }); + return positions; + } + + if (viewMode === "activity") { + const sorted = [...lanes].sort((a, b) => (activityScoreByLaneId[b.id] ?? 0) - (activityScoreByLaneId[a.id] ?? 0)); + const cols = Math.max(1, Math.ceil(Math.sqrt(sorted.length))); + sorted.forEach((lane, index) => { + positions[lane.id] = { + x: (index % cols) * 230, + y: Math.floor(index / cols) * 180 + }; + }); + return positions; + } + + const primary = lanes.find((lane) => lane.laneType === "primary") ?? lanes[0]!; + positions[primary.id] = { x: 420, y: 240 }; + const rest = lanes.filter((lane) => lane.id !== primary.id); + const radius = Math.max(180, rest.length * 22); + rest.forEach((lane, index) => { + const angle = (index / Math.max(1, rest.length)) * Math.PI * 2; + positions[lane.id] = { + x: 420 + Math.cos(angle) * radius, + y: 240 + Math.sin(angle) * radius + }; + }); + return positions; +} + +function collectDescendants(lanes: LaneSummary[], rootId: string): Set { + const childrenByParent = new Map(); + for (const lane of lanes) { + if (!lane.parentLaneId) continue; + const list = childrenByParent.get(lane.parentLaneId) ?? []; + list.push(lane.id); + childrenByParent.set(lane.parentLaneId, list); + } + const out = new Set(); + const queue = [rootId]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const child of childrenByParent.get(current) ?? []) { + if (out.has(child)) continue; + out.add(child); + queue.push(child); + } + } + return out; +} + +function GraphLaneNode({ data, selected }: NodeProps>) { + const lane = data.lane; + const dimensions = nodeDimensions(lane, data.activityBucket, data.viewMode); + const statusColor = + data.status === "conflict-active" || data.status === "conflict-predicted" + ? "text-red-300" + : data.status === "behind-base" + ? "text-amber-300" + : data.status === "merge-ready" + ? "text-emerald-300" + : "text-muted-fg"; + + return ( +
+
+ {iconGlyph(lane.icon)} + {lane.name} +
+
{lane.branchRef}
+
+ {lane.status.dirty ? "dirty" : "clean"} + {lane.status.ahead}↑/{lane.status.behind}↓ + {data.status} + {data.activeSessions > 0 ? : null} +
+ {lane.tags.length > 0 ? ( +
+ {lane.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ ) : null} + {data.collapsedChildCount > 0 ? ( +
+ + {data.collapsedChildCount} children +
+ ) : null} + + +
+
+ ); +} + +function RiskEdge(props: EdgeProps>) { + const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, markerEnd, data, selected } = props; + const [path] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition: sourcePosition ?? Position.Bottom, + targetPosition: targetPosition ?? Position.Top + }); + const color = data?.edgeType === "risk" ? riskStrokeColor(data.riskLevel) : data?.edgeType === "stack" ? "#38bdf8" : "#6b7280"; + const width = data?.edgeType === "stack" ? 3 : 1.8; + const dash = data?.edgeType === "risk" ? "5 3" : undefined; + const effectiveWidth = (selected ? width + 1 : width) + (data?.highlight ? 0.5 : 0); + const effectiveOpacity = data?.dimmed ? 0.16 : data?.highlight ? 1 : data?.stale ? 0.55 : 0.9; + return ( + + + + ); +} + +const nodeTypes = { lane: GraphLaneNode }; +const edgeTypes = { custom: RiskEdge }; + +function GraphInner() { + const navigate = useNavigate(); + const reactFlow = useReactFlow, Edge>(); + const project = useAppStore((s) => s.project); + const lanes = useAppStore((s) => s.lanes); + const refreshLanes = useAppStore((s) => s.refreshLanes); + + const [viewMode, setViewMode] = React.useState("all"); + const [graphState, setGraphState] = React.useState(createDefaultState()); + const [loadedGraphState, setLoadedGraphState] = React.useState(false); + const [nodes, setNodes] = React.useState>>([]); + const [edges, setEdges] = React.useState>>([]); + const [batch, setBatch] = React.useState(null); + const [batchProgress, setBatchProgress] = React.useState(null); + const [loadingTopology, setLoadingTopology] = React.useState(true); + const [loadingRisk, setLoadingRisk] = React.useState(true); + const [errorBanner, setErrorBanner] = React.useState(null); + const [contextMenu, setContextMenu] = React.useState<{ laneId: string; x: number; y: number } | null>(null); + const [selectedLaneIds, setSelectedLaneIds] = React.useState([]); + const [batchStatus, setBatchStatus] = React.useState<{ + operation: string; + steps: BatchStep[]; + activeIndex: number; + summary: string | null; + } | null>(null); + const [appearanceEditor, setAppearanceEditor] = React.useState<{ + laneId: string; + x: number; + y: number; + color: string | null; + icon: LaneIcon; + tags: string[]; + newTag: string; + } | null>(null); + const [reparentDialog, setReparentDialog] = React.useState<{ + laneIds: string[]; + targetLaneId: string; + overlapFiles: string[]; + preview: MergeSimulationResult | null; + previewBusy: boolean; + actionMode: "integrate" | "reparent"; + integratePlan: { + sourceLaneId: string; + laneId: string; + baseRef: string; + mode: GitSyncMode; + summary: string; + detail: string; + } | null; + } | null>(null); + const [undoToast, setUndoToast] = React.useState<{ + message: string; + undoAction: () => Promise; + } | null>(null); + const [activityScoreByLaneId, setActivityScoreByLaneId] = React.useState>({}); + const [activeSessionsByLaneId, setActiveSessionsByLaneId] = React.useState>({}); + const [lastActivityByLaneId, setLastActivityByLaneId] = React.useState>({}); + const [edgeHover, setEdgeHover] = React.useState<{ x: number; y: number; label: string } | null>(null); + const [dragTrail, setDragTrail] = React.useState<{ laneId: string; from: { x: number; y: number }; to: { x: number; y: number } } | null>(null); + const [dropPreview, setDropPreview] = React.useState<{ + draggedLaneIds: string[]; + targetLaneId: string; + tone: "safe" | "warn" | "blocked"; + message: string; + detail: string; + } | null>(null); + const [hoveredEdgeId, setHoveredEdgeId] = React.useState(null); + const [edgeSimulation, setEdgeSimulation] = React.useState<{ + laneAId: string; + laneBId: string; + loading: boolean; + result: MergeSimulationResult | null; + error: string | null; + } | null>(null); + const [hoveredNodeId, setHoveredNodeId] = React.useState(null); + const [nodeTooltip, setNodeTooltip] = React.useState<{ x: number; y: number; laneId: string } | null>(null); + const [restackFailedLaneId, setRestackFailedLaneId] = React.useState(null); + const [restackFailedPulse, setRestackFailedPulse] = React.useState(false); + const [textPrompt, setTextPrompt] = React.useState(null); + const [textPromptError, setTextPromptError] = React.useState(null); + + const persistTimerRef = React.useRef(null); + const riskRefreshTimerRef = React.useRef(null); + const dragOriginRef = React.useRef>(new Map()); + const dropPreviewTimerRef = React.useRef(null); + const nodeHoverTimerRef = React.useRef(null); + const lastFitViewKeyRef = React.useRef(""); + const nodeDragActiveRef = React.useRef(false); + const filtersPanelRef = React.useRef(null); + const [showFiltersPanel, setShowFiltersPanel] = React.useState(false); + + const preset = React.useMemo(() => { + const ensured = ensureGraphState(graphState); + return ensured.presets.find((entry) => entry.name === ensured.activePreset) ?? ensured.presets[0]!; + }, [graphState]); + + const activeSnapshot = React.useMemo(() => preset.byViewMode[viewMode], [preset, viewMode]); + const filters = activeSnapshot.filters; + + const requestTextInput = React.useCallback( + (args: { + title: string; + message?: string; + defaultValue?: string; + placeholder?: string; + confirmLabel?: string; + validate?: (value: string) => string | null; + }): Promise => { + return new Promise((resolve) => { + setTextPromptError(null); + setTextPrompt({ + title: args.title, + message: args.message, + placeholder: args.placeholder, + value: args.defaultValue ?? "", + confirmLabel: args.confirmLabel ?? "Confirm", + validate: args.validate, + resolve + }); + }); + }, + [] + ); + + const cancelTextPrompt = React.useCallback(() => { + setTextPrompt((prev) => { + if (prev) prev.resolve(null); + return null; + }); + setTextPromptError(null); + }, []); + + const submitTextPrompt = React.useCallback(() => { + setTextPrompt((prev) => { + if (!prev) return prev; + const value = prev.value.trim(); + const validationError = prev.validate?.(value) ?? null; + if (validationError) { + setTextPromptError(validationError); + return prev; + } + setTextPromptError(null); + prev.resolve(value); + return null; + }); + }, []); + + const statusByLane = React.useMemo(() => { + const map = new Map(); + for (const entry of batch?.lanes ?? []) map.set(entry.laneId, entry.status); + return map; + }, [batch]); + + const riskByPair = React.useMemo(() => { + const map = new Map(); + for (const entry of batch?.matrix ?? []) { + if (entry.laneAId === entry.laneBId) continue; + map.set(edgePairKey(entry.laneAId, entry.laneBId), { + riskLevel: entry.riskLevel, + overlapCount: entry.overlapCount, + stale: entry.stale + }); + } + return map; + }, [batch]); + + const overlapFilesByPair = React.useMemo(() => { + const map = new Map(); + for (const overlap of batch?.overlaps ?? []) { + map.set(edgePairKey(overlap.laneAId, overlap.laneBId), overlap.files); + } + return map; + }, [batch]); + + const collapsedLaneIds = React.useMemo(() => new Set(activeSnapshot.collapsedLaneIds), [activeSnapshot.collapsedLaneIds]); + + const hiddenByCollapse = React.useMemo(() => { + const hidden = new Set(); + for (const laneId of collapsedLaneIds) { + const descendants = collectDescendants(lanes, laneId); + for (const id of descendants) hidden.add(id); + } + return hidden; + }, [collapsedLaneIds, lanes]); + + const laneById = React.useMemo(() => new Map(lanes.map((lane) => [lane.id, lane] as const)), [lanes]); + + const connectedToHoveredNode = React.useMemo(() => { + if (!hoveredNodeId) return new Set(); + const connected = new Set([hoveredNodeId]); + + if (viewMode === "stack" || viewMode === "all") { + const primary = lanes.find((lane) => lane.laneType === "primary"); + if (primary?.id === hoveredNodeId) { + for (const lane of lanes) connected.add(lane.id); + } else if (primary && hoveredNodeId !== primary.id) { + connected.add(primary.id); + } + for (const lane of lanes) { + if (lane.id === hoveredNodeId && lane.parentLaneId) connected.add(lane.parentLaneId); + if (lane.parentLaneId === hoveredNodeId) connected.add(lane.id); + } + } + + if (viewMode === "risk" || viewMode === "all") { + for (const [key, value] of riskByPair.entries()) { + if (value.riskLevel === "none" && value.overlapCount === 0) continue; + const [laneAId, laneBId] = key.split("::"); + if (!laneAId || !laneBId) continue; + if (laneAId === hoveredNodeId) connected.add(laneBId); + if (laneBId === hoveredNodeId) connected.add(laneAId); + } + } + + return connected; + }, [hoveredNodeId, lanes, riskByPair, viewMode]); + + const laneMatchesFilters = React.useCallback( + (lane: LaneSummary): boolean => { + if (filters.hidePrimary && lane.laneType === "primary") return false; + if (filters.hideAttached && lane.laneType === "attached") return false; + if (filters.hideArchived && lane.archivedAt) return false; + if (filters.laneTypes.length > 0 && !filters.laneTypes.includes(lane.laneType)) return false; + if (filters.status.length > 0 && !filters.status.includes(laneStatusGroup(statusByLane.get(lane.id)))) return false; + if (filters.tags.length > 0 && !filters.tags.some((tag) => lane.tags.includes(tag))) return false; + if (filters.rootLaneId) { + const descendants = collectDescendants(lanes, filters.rootLaneId); + if (!descendants.has(lane.id) && lane.id !== filters.rootLaneId) return false; + } + if (filters.search.trim().length > 0) { + const needle = filters.search.trim().toLowerCase(); + const hay = `${lane.name} ${lane.branchRef} ${lane.tags.join(" ")}`.toLowerCase(); + if (!hay.includes(needle)) return false; + } + return true; + }, + [filters, lanes, statusByLane] + ); + + const activityBucketByLaneId = React.useMemo(() => { + const values = Object.values(activityScoreByLaneId).filter((value) => Number.isFinite(value)).sort((a, b) => a - b); + const p25 = values[Math.floor(values.length * 0.25)] ?? 0; + const p75 = values[Math.floor(values.length * 0.75)] ?? 0; + const out: Record = {}; + for (const lane of lanes) { + const score = activityScoreByLaneId[lane.id] ?? 0; + if (score <= 0) out[lane.id] = "min"; + else if (score < p25) out[lane.id] = "low"; + else if (score > p75) out[lane.id] = "high"; + else out[lane.id] = "medium"; + } + return out; + }, [activityScoreByLaneId, lanes]); + + const updateGraphSnapshot = React.useCallback( + (updater: (snapshot: GraphLayoutSnapshot) => GraphLayoutSnapshot) => { + setGraphState((prev) => { + const ensured = ensureGraphState(prev); + const nextPresets = ensured.presets.map((presetItem) => { + if (presetItem.name !== ensured.activePreset) return presetItem; + const currentSnapshot = presetItem.byViewMode[viewMode]; + const nextSnapshot = updater(currentSnapshot); + return { + ...presetItem, + updatedAt: new Date().toISOString(), + byViewMode: { + ...presetItem.byViewMode, + [viewMode]: { ...nextSnapshot, updatedAt: new Date().toISOString(), viewMode } + } + }; + }); + const nextState = { ...ensured, presets: nextPresets }; + if (persistTimerRef.current != null) { + window.clearTimeout(persistTimerRef.current); + } + persistTimerRef.current = window.setTimeout(() => { + if (project?.rootPath) { + void window.ade.graphState.set(project.rootPath, nextState).catch(() => {}); + } + }, 250); + return nextState; + }); + }, + [project?.rootPath, viewMode] + ); + + const refreshRiskBatch = React.useCallback(async () => { + try { + const next = await window.ade.conflicts.getBatchAssessment(); + setBatch(next); + setBatchProgress(next.progress ?? null); + } catch { + // best effort + } finally { + setLoadingRisk(false); + } + }, []); + + const refreshActivity = React.useCallback(async () => { + try { + const [sessions, operations] = await Promise.all([ + window.ade.sessions.list({ limit: 500 }), + window.ade.history.listOperations({ limit: 500 }) + ]); + const now = Date.now(); + const activeByLane: Record = {}; + const scoreByLane: Record = {}; + const latestActivityByLane: Record = {}; + + const markActivity = (laneId: string, ts: number) => { + if (!Number.isFinite(ts)) return; + latestActivityByLane[laneId] = Math.max(latestActivityByLane[laneId] ?? 0, ts); + }; + + for (const session of sessions) { + const startedAt = Date.parse(session.startedAt); + const endedAt = session.endedAt ? Date.parse(session.endedAt) : null; + if (session.status === "running") { + activeByLane[session.laneId] = (activeByLane[session.laneId] ?? 0) + 1; + scoreByLane[session.laneId] = (scoreByLane[session.laneId] ?? 0) + 50; + markActivity(session.laneId, startedAt); + } else if (endedAt != null && now - endedAt <= 60 * 60_000) { + scoreByLane[session.laneId] = (scoreByLane[session.laneId] ?? 0) + 20; + markActivity(session.laneId, endedAt); + } else if (!Number.isNaN(startedAt) && now - startedAt <= 60 * 60_000) { + scoreByLane[session.laneId] = (scoreByLane[session.laneId] ?? 0) + 10; + markActivity(session.laneId, startedAt); + } + } + for (const operation of operations) { + if (operation.kind !== "git_commit") continue; + const startedAt = Date.parse(operation.startedAt); + if (Number.isNaN(startedAt) || now - startedAt > 24 * 60 * 60_000) continue; + if (!operation.laneId) continue; + scoreByLane[operation.laneId] = (scoreByLane[operation.laneId] ?? 0) + 10; + markActivity(operation.laneId, startedAt); + } + setActiveSessionsByLaneId(activeByLane); + setActivityScoreByLaneId(scoreByLane); + const asIso: Record = {}; + for (const [laneId, ts] of Object.entries(latestActivityByLane)) { + if (!ts) continue; + asIso[laneId] = new Date(ts).toISOString(); + } + setLastActivityByLaneId(asIso); + } catch { + // ignore + } + }, []); + + React.useEffect(() => { + setLoadingTopology(true); + void refreshLanes() + .catch(() => {}) + .finally(() => setLoadingTopology(false)); + void refreshRiskBatch(); + void refreshActivity(); + }, [refreshLanes, refreshRiskBatch, refreshActivity]); + + React.useEffect(() => { + if (!project?.rootPath) return; + setLoadedGraphState(false); + void window.ade.graphState + .get(project.rootPath) + .then((state) => { + setGraphState(ensureGraphState(state)); + }) + .catch(() => { + setGraphState(createDefaultState()); + }) + .finally(() => { + setLoadedGraphState(true); + }); + }, [project?.rootPath]); + + React.useEffect(() => { + if (!undoToast) return; + const timer = window.setTimeout(() => setUndoToast(null), 10_000); + return () => window.clearTimeout(timer); + }, [undoToast]); + + React.useEffect(() => { + if (!batchStatus?.summary) return; + const hasFailure = batchStatus.steps.some((step) => step.status === "failed" || step.status === "skipped"); + if (hasFailure) return; + const timer = window.setTimeout(() => setBatchStatus(null), 5_000); + return () => window.clearTimeout(timer); + }, [batchStatus]); + + React.useEffect(() => { + return () => { + if (dropPreviewTimerRef.current != null) { + window.clearTimeout(dropPreviewTimerRef.current); + } + if (nodeHoverTimerRef.current != null) { + window.clearTimeout(nodeHoverTimerRef.current); + } + }; + }, []); + + React.useEffect(() => { + if (!showFiltersPanel) return; + const onPointerDown = (event: PointerEvent) => { + const panel = filtersPanelRef.current; + if (!panel) return; + const target = event.target; + if (!(target instanceof globalThis.Node)) { + setShowFiltersPanel(false); + return; + } + if (panel.contains(target)) return; + setShowFiltersPanel(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setShowFiltersPanel(false); + }; + window.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [showFiltersPanel]); + + React.useEffect(() => { + const unsubConflict = window.ade.conflicts.onEvent((event) => { + if (event.type === "prediction-progress") { + setBatchProgress({ completedPairs: event.completedPairs, totalPairs: event.totalPairs }); + if (riskRefreshTimerRef.current != null) { + window.clearTimeout(riskRefreshTimerRef.current); + } + riskRefreshTimerRef.current = window.setTimeout(() => { + void refreshRiskBatch(); + }, 450); + return; + } + setBatchProgress({ completedPairs: event.completedPairs, totalPairs: event.totalPairs }); + void refreshRiskBatch(); + }); + const unsubPtyData = window.ade.pty.onData(() => { + void refreshActivity(); + }); + const unsubPtyExit = window.ade.pty.onExit(() => { + void refreshActivity(); + }); + const interval = window.setInterval(() => { + void refreshLanes().catch(() => {}); + void refreshActivity(); + }, 5000); + + return () => { + unsubConflict(); + unsubPtyData(); + unsubPtyExit(); + window.clearInterval(interval); + if (riskRefreshTimerRef.current != null) { + window.clearTimeout(riskRefreshTimerRef.current); + } + }; + }, [refreshActivity, refreshLanes, refreshRiskBatch]); + + React.useEffect(() => { + if (!loadedGraphState) return; + if (nodeDragActiveRef.current) return; + const autoPositions = computeAutoLayout(lanes, viewMode, activityScoreByLaneId); + const savedPositions = activeSnapshot.nodePositions; + const positions = Object.keys(savedPositions).length > 0 ? { ...autoPositions, ...savedPositions } : autoPositions; + + const nextNodes: Array> = []; + for (const lane of lanes) { + if (hiddenByCollapse.has(lane.id)) continue; + const pos = positions[lane.id] ?? { x: 0, y: 0 }; + const visible = laneMatchesFilters(lane); + const descendants = collectDescendants(lanes, lane.id); + const collapsedChildCount = collapsedLaneIds.has(lane.id) + ? descendants.size + : 0; + const connectedToHover = hoveredNodeId ? connectedToHoveredNode.has(lane.id) : false; + const dimmedByHover = Boolean(hoveredNodeId) && !connectedToHover; + nextNodes.push({ + id: lane.id, + type: "lane", + position: pos, + data: { + lane: appearanceEditor?.laneId === lane.id + ? { ...lane, color: appearanceEditor.color, icon: appearanceEditor.icon, tags: appearanceEditor.tags } + : lane, + status: statusByLane.get(lane.id) ?? "unknown", + activeSessions: activeSessionsByLaneId[lane.id] ?? 0, + collapsedChildCount, + dimmed: !visible || dimmedByHover, + activityBucket: activityBucketByLaneId[lane.id] ?? "medium", + viewMode, + lastActivityAt: lastActivityByLaneId[lane.id] ?? null, + highlight: Boolean(hoveredNodeId) && connectedToHover, + restackFailed: restackFailedLaneId === lane.id, + restackPulse: restackFailedLaneId === lane.id && restackFailedPulse + }, + selected: selectedLaneIds.includes(lane.id), + draggable: true + }); + } + setNodes(nextNodes); + + const nextEdges: Array> = []; + const primaryLane = lanes.find((lane) => lane.laneType === "primary") ?? null; + const edgeVisualState = (edgeId: string, source: string, target: string) => { + const connectedToNodeHover = hoveredNodeId ? source === hoveredNodeId || target === hoveredNodeId : false; + const highlightedByEdge = hoveredEdgeId ? hoveredEdgeId === edgeId : false; + const highlight = hoveredEdgeId ? highlightedByEdge : connectedToNodeHover; + const dimmed = hoveredEdgeId + ? hoveredEdgeId !== edgeId + : hoveredNodeId + ? !connectedToNodeHover + : false; + return { highlight, dimmed }; + }; + + if (viewMode === "all" || viewMode === "stack") { + for (const lane of lanes) { + if (!primaryLane || lane.id === primaryLane.id) continue; + const edgeId = `topology:${primaryLane.id}:${lane.id}`; + const visual = edgeVisualState(edgeId, primaryLane.id, lane.id); + nextEdges.push({ + id: edgeId, + source: primaryLane.id, + target: lane.id, + sourceHandle: "source", + targetHandle: "target", + type: "custom", + data: { edgeType: "topology", ...visual }, + markerEnd: { type: MarkerType.ArrowClosed }, + animated: false, + selected: visual.highlight + }); + } + for (const lane of lanes) { + if (!lane.parentLaneId || !laneById.has(lane.parentLaneId)) continue; + const edgeId = `stack:${lane.parentLaneId}:${lane.id}`; + const visual = edgeVisualState(edgeId, lane.parentLaneId, lane.id); + nextEdges.push({ + id: edgeId, + source: lane.parentLaneId, + target: lane.id, + sourceHandle: "source", + targetHandle: "target", + type: "custom", + data: { edgeType: "stack", ...visual }, + markerEnd: { type: MarkerType.ArrowClosed }, + selected: visual.highlight + }); + } + } + + if (viewMode === "all" || viewMode === "risk") { + for (const [key, risk] of riskByPair.entries()) { + if (risk.riskLevel === "none" && risk.overlapCount === 0) continue; + const [laneAId, laneBId] = key.split("::"); + if (!laneAId || !laneBId) continue; + if (hiddenByCollapse.has(laneAId) || hiddenByCollapse.has(laneBId)) continue; + const edgeId = `risk:${laneAId}:${laneBId}`; + const visual = edgeVisualState(edgeId, laneAId, laneBId); + nextEdges.push({ + id: edgeId, + source: laneAId, + target: laneBId, + sourceHandle: "source", + targetHandle: "target", + type: "custom", + data: { + edgeType: "risk", + riskLevel: risk.riskLevel, + overlapCount: risk.overlapCount, + stale: risk.stale, + ...visual + }, + selected: visual.highlight + }); + } + } + + setEdges(nextEdges); + }, [ + activityBucketByLaneId, + activeSessionsByLaneId, + activeSnapshot.nodePositions, + appearanceEditor, + collapsedLaneIds, + connectedToHoveredNode, + hiddenByCollapse, + hoveredNodeId, + laneById, + laneMatchesFilters, + lanes, + lastActivityByLaneId, + loadedGraphState, + restackFailedLaneId, + restackFailedPulse, + riskByPair, + selectedLaneIds, + statusByLane, + viewMode, + hoveredEdgeId, + activityScoreByLaneId + ]); + + const onNodesChange = React.useCallback((changes: Parameters>>[0]) => { + setNodes((prev) => applyNodeChanges(changes, prev)); + }, []); + + const onEdgesChange = React.useCallback((changes: Parameters>>[0]) => { + setEdges((prev) => applyEdgeChanges(changes, prev)); + }, []); + + const saveNodePositions = React.useCallback( + (nextNodes: Array>) => { + const nodePositions: GraphLayoutSnapshot["nodePositions"] = {}; + for (const node of nextNodes) { + nodePositions[node.id] = { x: node.position.x, y: node.position.y }; + } + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + nodePositions + })); + }, + [updateGraphSnapshot] + ); + + const findDropTarget = React.useCallback( + (node: Node): Node | null => { + const targetCandidates = nodes.filter((candidate) => candidate.id !== node.id && !hiddenByCollapse.has(candidate.id)); + const nodeDims = nodeDimensions(node.data.lane, node.data.activityBucket, viewMode); + const nodeCenter = { x: node.position.x + nodeDims.width / 2, y: node.position.y + nodeDims.height / 2 }; + for (const candidate of targetCandidates) { + const dims = nodeDimensions(candidate.data.lane, candidate.data.activityBucket, viewMode); + if ( + nodeCenter.x >= candidate.position.x && + nodeCenter.x <= candidate.position.x + dims.width && + nodeCenter.y >= candidate.position.y && + nodeCenter.y <= candidate.position.y + dims.height + ) { + return candidate; + } + } + return null; + }, + [hiddenByCollapse, nodes, viewMode] + ); + + const getDropIntegratePlan = React.useCallback( + (sourceLaneId: string, targetLaneId: string) => { + const source = laneById.get(sourceLaneId); + const target = laneById.get(targetLaneId); + if (!source || !target) return null; + + const sourceDescendants = collectDescendants(lanes, source.id); + const targetDescendants = collectDescendants(lanes, target.id); + if (sourceDescendants.has(target.id)) { + return { + sourceLaneId: source.id, + laneId: target.id, + baseRef: source.branchRef, + mode: "rebase" as GitSyncMode, + summary: `Rebase '${target.name}' onto '${source.name}'`, + detail: `Bring ${source.branchRef} into ${target.name} with rebase.` + }; + } + return { + sourceLaneId: source.id, + laneId: target.id, + baseRef: source.branchRef, + mode: "merge" as GitSyncMode, + summary: `Merge '${source.name}' into '${target.name}'`, + detail: `Bring ${source.branchRef} into ${target.name} with merge.` + }; + }, + [laneById, lanes] + ); + + const onNodeDragStart = React.useCallback((_event: React.MouseEvent, node: Node) => { + nodeDragActiveRef.current = true; + dragOriginRef.current.set(node.id, { x: node.position.x, y: node.position.y }); + if (dropPreviewTimerRef.current != null) { + window.clearTimeout(dropPreviewTimerRef.current); + dropPreviewTimerRef.current = null; + } + setDropPreview(null); + setDragTrail({ laneId: node.id, from: { x: node.position.x, y: node.position.y }, to: { x: node.position.x, y: node.position.y } }); + }, []); + + const onNodeDrag = React.useCallback( + (_event: React.MouseEvent, node: Node) => { + const origin = dragOriginRef.current.get(node.id); + if (!origin) return; + setDragTrail({ laneId: node.id, from: origin, to: { x: node.position.x, y: node.position.y } }); + + const target = findDropTarget(node); + if (!target) { + if (dropPreviewTimerRef.current != null) { + window.clearTimeout(dropPreviewTimerRef.current); + dropPreviewTimerRef.current = null; + } + setDropPreview(null); + return; + } + + const draggedLaneIds = selectedLaneIds.includes(node.id) && selectedLaneIds.length > 1 ? selectedLaneIds : [node.id]; + let nextPreview: + | { + draggedLaneIds: string[]; + targetLaneId: string; + tone: "safe" | "warn" | "blocked"; + message: string; + detail: string; + } + | null = null; + const wouldCycle = draggedLaneIds.some((laneId) => { + if (laneId === target.id) return true; + return collectDescendants(lanes, laneId).has(target.id); + }); + if (wouldCycle) { + nextPreview = { + draggedLaneIds, + targetLaneId: target.id, + tone: "blocked", + message: "Cannot change parent (cycle detected).", + detail: "Pick a lane that is not inside the dragged lane's descendant chain." + }; + } else { + let overlapCount = 0; + for (const laneId of draggedLaneIds) { + if (laneId === target.id) continue; + const overlapFiles = overlapFilesByPair.get(edgePairKey(laneId, target.id)) ?? []; + overlapCount = Math.max(overlapCount, overlapFiles.length); + } + if (draggedLaneIds.length === 1) { + const plan = getDropIntegratePlan(draggedLaneIds[0]!, target.id); + if (!plan) { + nextPreview = { + draggedLaneIds, + targetLaneId: target.id, + tone: "warn", + message: "Drop action unavailable for this lane pair.", + detail: "Try again after lane topology refresh." + }; + } else if (overlapCount > 0) { + nextPreview = { + draggedLaneIds, + targetLaneId: target.id, + tone: "warn", + message: `${plan.summary} (⚠ ${overlapCount} overlapping files).`, + detail: `${plan.detail} Use Reparent when you want to change stack hierarchy instead of integrating commits.` + }; + } else { + nextPreview = { + draggedLaneIds, + targetLaneId: target.id, + tone: "safe", + message: plan.summary, + detail: `${plan.detail} Use Reparent when you want to change stack hierarchy instead of integrating commits.` + }; + } + } else if (overlapCount > 0) { + nextPreview = { + draggedLaneIds, + targetLaneId: target.id, + tone: "warn", + message: `Reparent ${draggedLaneIds.length} lanes under ${target.data.lane.name} (⚠ ${overlapCount} overlapping files).`, + detail: "Multi-lane drop updates stack parent + base ref and rebases in dependency order." + }; + } else { + nextPreview = { + draggedLaneIds, + targetLaneId: target.id, + tone: "safe", + message: `Reparent ${draggedLaneIds.length} lanes under ${target.data.lane.name}.`, + detail: "Multi-lane drop updates stack parent + base ref and rebases in dependency order." + }; + } + } + + if (dropPreviewTimerRef.current != null) { + window.clearTimeout(dropPreviewTimerRef.current); + } + dropPreviewTimerRef.current = window.setTimeout(() => { + setDropPreview(nextPreview); + }, 200); + }, + [findDropTarget, getDropIntegratePlan, lanes, overlapFilesByPair, selectedLaneIds] + ); + + const openReparentDialog = React.useCallback( + (draggedLaneId: string, targetLaneId: string, laneIds: string[]) => { + const lane = laneById.get(draggedLaneId); + const target = laneById.get(targetLaneId); + if (!lane || !target) return; + if (lane.id === target.id) return; + + const wouldCycle = laneIds.some((laneId) => { + if (laneId === targetLaneId) return true; + return collectDescendants(lanes, laneId).has(targetLaneId); + }); + if (wouldCycle) { + setErrorBanner("Cannot reparent — would create cycle."); + return; + } + + const overlapFiles = Array.from( + laneIds.reduce((acc, laneId) => { + for (const file of overlapFilesByPair.get(edgePairKey(laneId, targetLaneId)) ?? []) { + acc.add(file); + } + return acc; + }, new Set()) + ).sort((a, b) => a.localeCompare(b)); + const integratePlan = laneIds.length === 1 ? getDropIntegratePlan(laneIds[0]!, targetLaneId) : null; + setReparentDialog({ + laneIds, + targetLaneId, + overlapFiles, + preview: null, + previewBusy: false, + actionMode: integratePlan ? "integrate" : "reparent", + integratePlan + }); + }, + [getDropIntegratePlan, laneById, lanes, overlapFilesByPair] + ); + + const onNodeDragStop = React.useCallback( + (_event: React.MouseEvent, node: Node) => { + nodeDragActiveRef.current = false; + const origin = dragOriginRef.current.get(node.id); + setDragTrail(null); + if (dropPreviewTimerRef.current != null) { + window.clearTimeout(dropPreviewTimerRef.current); + dropPreviewTimerRef.current = null; + } + setDropPreview(null); + dragOriginRef.current.delete(node.id); + const latestNodes = reactFlow.getNodes(); + saveNodePositions(latestNodes.map((existing) => (existing.id === node.id ? { ...existing, position: node.position } : existing))); + + const target = findDropTarget(node); + if (!target) return; + + if (origin) { + const dx = node.position.x - origin.x; + const dy = node.position.y - origin.y; + const moved = Math.sqrt(dx * dx + dy * dy); + if (moved < 5) return; + } + + const selectedIds = selectedLaneIds.includes(node.id) && selectedLaneIds.length > 1 ? selectedLaneIds : [node.id]; + openReparentDialog(node.id, target.id, selectedIds); + }, + [findDropTarget, openReparentDialog, reactFlow, saveNodePositions, selectedLaneIds] + ); + + const applyReparent = React.useCallback(async () => { + if (!reparentDialog) return; + if (reparentDialog.actionMode === "integrate") { + const plan = reparentDialog.integratePlan; + if (!plan) return; + try { + await window.ade.git.sync({ + laneId: plan.laneId, + mode: plan.mode, + baseRef: plan.baseRef + }); + setReparentDialog(null); + await refreshLanes().catch(() => {}); + } catch (error) { + setErrorBanner(error instanceof Error ? error.message : String(error)); + } + return; + } + + const target = laneById.get(reparentDialog.targetLaneId); + if (!target) return; + + const orderedLaneIds = [...reparentDialog.laneIds].sort((a, b) => { + const laneA = laneById.get(a); + const laneB = laneById.get(b); + return (laneA?.stackDepth ?? 0) - (laneB?.stackDepth ?? 0); + }); + + const completed: Array<{ laneId: string; previousParentLaneId: string | null }> = []; + for (const laneId of orderedLaneIds) { + try { + const result = await window.ade.lanes.reparent({ laneId, newParentLaneId: target.id }); + completed.push({ laneId, previousParentLaneId: result.previousParentLaneId }); + } catch (error) { + for (const rollback of completed.reverse()) { + if (!rollback.previousParentLaneId) continue; + try { + await window.ade.lanes.reparent({ laneId: rollback.laneId, newParentLaneId: rollback.previousParentLaneId }); + } catch { + // best effort rollback + } + } + setErrorBanner(error instanceof Error ? error.message : String(error)); + setReparentDialog(null); + await refreshLanes().catch(() => {}); + return; + } + } + + setUndoToast({ + message: `Reparented ${orderedLaneIds.length === 1 ? `'${laneById.get(orderedLaneIds[0]!)?.name ?? orderedLaneIds[0]}'` : `${orderedLaneIds.length} lanes`} under '${target.name}'`, + undoAction: async () => { + for (const rollback of completed.reverse()) { + if (!rollback.previousParentLaneId) continue; + await window.ade.lanes.reparent({ laneId: rollback.laneId, newParentLaneId: rollback.previousParentLaneId }); + } + await refreshLanes(); + } + }); + setReparentDialog(null); + await refreshLanes().catch(() => {}); + }, [laneById, refreshLanes, reparentDialog]); + + const runBatchOperation = React.useCallback( + async (operation: "restack" | "push" | "fetch" | "archive" | "delete") => { + if (selectedLaneIds.length < 2) return; + if (operation === "restack") { + setRestackFailedLaneId(null); + setRestackFailedPulse(false); + } + const steps = selectedLaneIds.map((laneId) => ({ + laneId, + laneName: laneById.get(laneId)?.name ?? laneId, + status: "pending" as const + })); + setBatchStatus({ + operation, + steps, + activeIndex: 0, + summary: null + }); + + const descendantsCache = new Map>(); + for (const laneId of selectedLaneIds) descendantsCache.set(laneId, collectDescendants(lanes, laneId)); + const blocked = new Set(); + const ordered = operation === "restack" + ? [...selectedLaneIds].sort((a, b) => (laneById.get(a)?.stackDepth ?? 0) - (laneById.get(b)?.stackDepth ?? 0)) + : [...selectedLaneIds]; + + let failedCount = 0; + let doneCount = 0; + let skippedCount = 0; + for (let index = 0; index < ordered.length; index += 1) { + const laneId = ordered[index]!; + setBatchStatus((prev) => { + if (!prev) return prev; + const nextSteps = [...prev.steps]; + const stepIndex = nextSteps.findIndex((step) => step.laneId === laneId); + if (stepIndex >= 0) nextSteps[stepIndex] = { ...nextSteps[stepIndex]!, status: "running" as const }; + return { ...prev, steps: nextSteps, activeIndex: index }; + }); + + if (blocked.has(laneId)) { + skippedCount += 1; + setBatchStatus((prev) => { + if (!prev) return prev; + const nextSteps = prev.steps.map((step) => step.laneId === laneId ? { ...step, status: "skipped" as const, error: "blocked by parent failure" } : step); + return { ...prev, steps: nextSteps }; + }); + continue; + } + + try { + if (operation === "restack") { + const result = await window.ade.lanes.restack({ laneId, recursive: false }); + if (result.error) throw new Error(result.error); + } else if (operation === "push") { + await window.ade.git.push({ laneId }); + } else if (operation === "fetch") { + await window.ade.git.fetch({ laneId }); + } else if (operation === "archive") { + await window.ade.lanes.archive({ laneId }); + } else { + await window.ade.lanes.delete({ laneId, force: true, deleteBranch: false }); + } + doneCount += 1; + setBatchStatus((prev) => { + if (!prev) return prev; + const nextSteps = prev.steps.map((step) => step.laneId === laneId ? { ...step, status: "done" as const } : step); + return { ...prev, steps: nextSteps }; + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const missingLane = /lane not found|no longer exists/i.test(message); + if (missingLane) { + skippedCount += 1; + setBatchStatus((prev) => { + if (!prev) return prev; + const nextSteps = prev.steps.map((step) => + step.laneId === laneId ? { ...step, status: "skipped" as const, error: "no longer exists" } : step + ); + return { ...prev, steps: nextSteps }; + }); + continue; + } + + failedCount += 1; + setBatchStatus((prev) => { + if (!prev) return prev; + const nextSteps = prev.steps.map((step) => + step.laneId === laneId ? { ...step, status: "failed" as const, error: message } : step + ); + return { ...prev, steps: nextSteps }; + }); + if (operation === "restack") { + const descendants = descendantsCache.get(laneId); + for (const childId of descendants ?? []) blocked.add(childId); + setRestackFailedLaneId(laneId); + setRestackFailedPulse(true); + window.setTimeout(() => setRestackFailedPulse(false), 1650); + setErrorBanner(`Rebase paused: conflict on '${laneById.get(laneId)?.name ?? laneId}'. ${doneCount}/${ordered.length} lanes rebased.`); + } + } + } + + setBatchStatus((prev) => { + if (!prev) return prev; + return { + ...prev, + summary: `${doneCount}/${ordered.length} done, ${failedCount} failed${skippedCount > 0 ? `, ${skippedCount} skipped` : ""}` + }; + }); + await refreshLanes().catch(() => {}); + }, + [laneById, lanes, refreshLanes, selectedLaneIds] + ); + + const openContextForSelected = React.useCallback(() => { + if (selectedLaneIds.length !== 1) return; + const laneId = selectedLaneIds[0]!; + const node = nodes.find((entry) => entry.id === laneId); + if (!node) return; + setContextMenu({ laneId, x: 240, y: 200 }); + }, [nodes, selectedLaneIds]); + + React.useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.shiftKey && event.key === "Enter") { + event.preventDefault(); + openContextForSelected(); + return; + } + if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; + if (selectedLaneIds.length !== 1) return; + const currentId = selectedLaneIds[0]!; + const current = laneById.get(currentId); + if (!current) return; + + let nextLane: LaneSummary | null = null; + if (event.key === "ArrowUp" || event.key === "ArrowLeft") { + if (current.parentLaneId) nextLane = laneById.get(current.parentLaneId) ?? null; + } else { + nextLane = lanes.find((lane) => lane.parentLaneId === current.id) ?? null; + } + if (!nextLane) return; + event.preventDefault(); + setSelectedLaneIds([nextLane.id]); + setNodes((prev) => prev.map((node) => ({ ...node, selected: node.id === nextLane.id }))); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [laneById, lanes, openContextForSelected, selectedLaneIds]); + + const applyContextAction = React.useCallback( + async (action: string) => { + if (!contextMenu) return; + const lane = laneById.get(contextMenu.laneId); + if (!lane) return; + + try { + if (action === "open") { + await window.ade.lanes.openFolder({ laneId: lane.id }); + } else if (action === "create-child") { + const name = await requestTextInput({ + title: "Child lane name", + validate: (value) => (value ? null : "Lane name is required") + }); + if (!name) return; + await window.ade.lanes.createChild({ parentLaneId: lane.id, name }); + await refreshLanes(); + } else if (action === "archive") { + await window.ade.lanes.archive({ laneId: lane.id }); + await refreshLanes(); + } else if (action === "delete") { + const confirmText = await requestTextInput({ + title: `Type delete ${lane.name} to confirm`, + validate: (value) => (value ? null : "Confirmation text is required") + }); + if (confirmText?.trim().toLowerCase() !== `delete ${lane.name}`.toLowerCase()) return; + await window.ade.lanes.delete({ laneId: lane.id, force: true, deleteBranch: false }); + await refreshLanes(); + } else if (action === "restack") { + const result = await window.ade.lanes.restack({ laneId: lane.id, recursive: false }); + if (result.error) throw new Error(result.error); + await refreshLanes(); + } else if (action === "push") { + await window.ade.git.push({ laneId: lane.id }); + } else if (action === "fetch") { + await window.ade.git.fetch({ laneId: lane.id }); + } else if (action === "sync") { + await window.ade.git.sync({ laneId: lane.id, mode: "rebase" }); + await refreshLanes(); + } else if (action === "reparent") { + const options = lanes.filter((entry) => entry.id !== lane.id).map((entry) => `${entry.id}:${entry.name}`).join("\n"); + const picked = await requestTextInput({ + title: "Enter target lane id", + message: options || "No candidate lanes available.", + validate: (value) => (value ? null : "Lane id is required") + }); + if (!picked) return; + const targetId = picked.trim(); + if (!laneById.has(targetId)) throw new Error("Unknown target lane id"); + openReparentDialog(lane.id, targetId, [lane.id]); + } else if (action === "rename") { + const name = await requestTextInput({ + title: "New lane name", + defaultValue: lane.name, + validate: (value) => (value ? null : "Lane name is required") + }); + if (!name) return; + await window.ade.lanes.rename({ laneId: lane.id, name }); + await refreshLanes(); + } else if (action === "customize") { + setAppearanceEditor({ + laneId: lane.id, + x: contextMenu.x + 20, + y: contextMenu.y, + color: lane.color, + icon: lane.icon, + tags: [...lane.tags], + newTag: "" + }); + } else if (action === "collapse") { + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + collapsedLaneIds: Array.from(new Set([...snapshot.collapsedLaneIds, lane.id])) + })); + } else if (action === "expand") { + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + collapsedLaneIds: snapshot.collapsedLaneIds.filter((id) => id !== lane.id) + })); + } + } catch (error) { + setErrorBanner(error instanceof Error ? error.message : String(error)); + } finally { + setContextMenu(null); + } + }, + [contextMenu, laneById, lanes, openReparentDialog, refreshLanes, requestTextInput, updateGraphSnapshot] + ); + + const lanesForLegend = React.useMemo(() => { + const map = new Map(); + for (const lane of lanes) { + if (!lane.color) continue; + map.set(lane.color, lane.name); + } + return Array.from(map.entries()).slice(0, 8); + }, [lanes]); + + const availableTags = React.useMemo(() => { + const tags = new Set(); + for (const lane of lanes) { + for (const tag of lane.tags) { + if (tag.trim()) tags.add(tag.trim()); + } + } + return Array.from(tags).sort((a, b) => a.localeCompare(b)).slice(0, 14); + }, [lanes]); + + const rootLaneOptions = React.useMemo( + () => lanes.filter((lane) => !lane.parentLaneId).sort((a, b) => a.name.localeCompare(b.name)), + [lanes] + ); + + const loadPreset = React.useCallback( + (presetName: string) => { + setGraphState((prev) => { + const ensured = ensureGraphState(prev); + if (!ensured.presets.some((presetItem) => presetItem.name === presetName)) return ensured; + return { ...ensured, activePreset: presetName }; + }); + }, + [] + ); + + const saveLayoutAsPreset = React.useCallback(async () => { + const presetName = await requestTextInput({ + title: "Preset name", + validate: (value) => (value ? null : "Preset name is required") + }); + if (!presetName) return; + setGraphState((prev) => { + const ensured = ensureGraphState(prev); + const existing = ensured.presets.find((entry) => entry.name === ensured.activePreset) ?? ensured.presets[0]!; + const nextPreset: GraphLayoutPreset = { + name: presetName, + byViewMode: existing.byViewMode, + updatedAt: new Date().toISOString() + }; + return { + ...ensured, + activePreset: presetName, + presets: [...ensured.presets.filter((entry) => entry.name !== presetName), nextPreset] + }; + }); + }, [requestTextInput]); + + React.useEffect(() => { + if (!loadedGraphState) return; + const fitKey = `${viewMode}:${nodes.length}:${edges.length}:${activeSnapshot.updatedAt}`; + if (lastFitViewKeyRef.current === fitKey) return; + lastFitViewKeyRef.current = fitKey; + const timer = window.setTimeout(() => { + void reactFlow.fitView({ duration: 500, padding: 0.2 }); + }, 0); + return () => window.clearTimeout(timer); + }, [activeSnapshot.updatedAt, edges.length, loadedGraphState, nodes.length, reactFlow, viewMode]); + + React.useEffect(() => { + const needle = filters.search.trim().toLowerCase(); + if (!needle) return; + const matching = nodes.filter((node) => { + const lane = node.data.lane; + const hay = `${lane.name} ${lane.branchRef} ${lane.tags.join(" ")}`.toLowerCase(); + return hay.includes(needle); + }); + if (matching.length === 0) return; + const timer = window.setTimeout(() => { + void reactFlow.fitView({ nodes: matching, duration: 320, padding: 0.25 }); + }, 140); + return () => window.clearTimeout(timer); + }, [filters.search, nodes, reactFlow]); + + const hoveredTooltipLane = nodeTooltip ? laneById.get(nodeTooltip.laneId) ?? null : null; + const dragTrailScreen = React.useMemo(() => { + if (!dragTrail) return null; + const viewport = reactFlow.getViewport(); + const x1 = dragTrail.from.x * viewport.zoom + viewport.x; + const y1 = dragTrail.from.y * viewport.zoom + viewport.y; + const x2 = dragTrail.to.x * viewport.zoom + viewport.x; + const y2 = dragTrail.to.y * viewport.zoom + viewport.y; + return { x1, y1, x2, y2 }; + }, [dragTrail, reactFlow]); + + if (loadingTopology) { + return ( +
+
+
+
+
+
+
+ + Loading topology… +
+
+
+
+ ); + } + + if (lanes.length === 0) { + return ( +
+
+
+ +
+
+ ); + } + + return ( +
+
+ +
+
+
+ {VIEW_MODES.map((mode) => ( + + ))} +
+ +
+ + { + const value = event.target.value; + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + filters: { ...snapshot.filters, search: value } + })); + }} + placeholder="Filter…" + className="h-7 w-[220px] rounded border border-border bg-card/70 pl-7 pr-2 text-xs outline-none" + /> +
+ + + + + + +
+ + {showFiltersPanel ? ( +
+
+ Drag-drop integrates commits by default; use Reparent when you want to change stack hierarchy. +
+
+
Status
+
+ {(["conflict", "at-risk", "clean", "unknown"] as GraphStatusFilter[]).map((status) => ( + + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + filters: { + ...snapshot.filters, + status: snapshot.filters.status.includes(status) + ? snapshot.filters.status.filter((entry) => entry !== status) + : [...snapshot.filters.status, status] + } + })) + } + className={cn( + "cursor-pointer", + filters.status.includes(status) && + (status === "conflict" + ? "bg-red-500/30 text-red-200" + : status === "at-risk" + ? "bg-amber-500/30 text-amber-200" + : status === "clean" + ? "bg-emerald-500/25 text-emerald-200" + : "bg-muted text-fg") + )} + > + {status} + + ))} +
+
+
+
Lane Type
+
+ {(["worktree", "attached", "primary"] as LaneSummary["laneType"][]).map((laneType) => ( + + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + filters: { + ...snapshot.filters, + laneTypes: snapshot.filters.laneTypes.includes(laneType) + ? snapshot.filters.laneTypes.filter((entry) => entry !== laneType) + : [...snapshot.filters.laneTypes, laneType] + } + })) + } + className={cn("cursor-pointer", filters.laneTypes.includes(laneType) && "bg-accent/30 text-accent-fg")} + > + {laneType} + + ))} +
+
+
+
Visibility
+
+ + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + filters: { + ...snapshot.filters, + hidePrimary: !snapshot.filters.hidePrimary + } + })) + } + className={cn("cursor-pointer", filters.hidePrimary && "bg-muted text-fg")} + > + hide primary + + + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + filters: { + ...snapshot.filters, + hideAttached: !snapshot.filters.hideAttached + } + })) + } + className={cn("cursor-pointer", filters.hideAttached && "bg-muted text-fg")} + > + hide attached + + + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + filters: { + ...snapshot.filters, + hideArchived: !snapshot.filters.hideArchived + } + })) + } + className={cn("cursor-pointer", filters.hideArchived && "bg-muted text-fg")} + > + hide archived + +
+
+
+ + +
+
+ ) : null} +
+
+
+ +
+ , Edge> + nodes={nodes} + edges={edges} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onNodeDragStart={onNodeDragStart} + onNodeDrag={onNodeDrag} + onNodeDragStop={onNodeDragStop} + onNodeClick={(_event, node) => { + setSelectedLaneIds([node.id]); + setNodes((prev) => prev.map((entry) => ({ ...entry, selected: entry.id === node.id }))); + if (collapsedLaneIds.has(node.id)) { + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + collapsedLaneIds: snapshot.collapsedLaneIds.filter((entry) => entry !== node.id) + })); + return; + } + navigate(`/lanes?laneId=${encodeURIComponent(node.id)}&focus=single`); + }} + onNodeMouseEnter={(event, node) => { + setHoveredNodeId(node.id); + if (nodeHoverTimerRef.current != null) { + window.clearTimeout(nodeHoverTimerRef.current); + } + nodeHoverTimerRef.current = window.setTimeout(() => { + setNodeTooltip({ x: event.clientX + 12, y: event.clientY + 12, laneId: node.id }); + }, 400); + }} + onNodeMouseMove={(event, node) => { + if (nodeTooltip?.laneId !== node.id) return; + setNodeTooltip({ x: event.clientX + 12, y: event.clientY + 12, laneId: node.id }); + }} + onNodeMouseLeave={() => { + setHoveredNodeId(null); + if (nodeHoverTimerRef.current != null) { + window.clearTimeout(nodeHoverTimerRef.current); + nodeHoverTimerRef.current = null; + } + setNodeTooltip(null); + }} + onSelectionChange={(selection) => { + const selected = selection.nodes.map((node) => node.id); + setSelectedLaneIds((prev) => (sameIdSet(prev, selected) ? prev : selected)); + }} + onNodeContextMenu={(event, node) => { + event.preventDefault(); + setContextMenu({ + laneId: node.id, + x: event.clientX, + y: event.clientY + }); + }} + onNodeDoubleClick={(_event, node) => { + if (!collapsedLaneIds.has(node.id)) return; + updateGraphSnapshot((snapshot) => ({ + ...snapshot, + collapsedLaneIds: snapshot.collapsedLaneIds.filter((entry) => entry !== node.id) + })); + }} + onEdgeClick={(_event, edge) => { + const [prefix, laneAId, laneBId] = edge.id.split(":"); + if (!laneAId || !laneBId) return; + if (prefix === "risk" || prefix === "stack" || prefix === "topology") { + setReparentDialog(null); + setContextMenu(null); + setEdgeSimulation({ + laneAId, + laneBId, + loading: true, + result: null, + error: null + }); + void window.ade.conflicts + .simulateMerge({ laneAId, laneBId }) + .then((result) => { + setEdgeSimulation((prev) => + prev && prev.laneAId === laneAId && prev.laneBId === laneBId + ? { ...prev, loading: false, result } + : prev + ); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + setEdgeSimulation((prev) => + prev && prev.laneAId === laneAId && prev.laneBId === laneBId + ? { ...prev, loading: false, error: message } + : prev + ); + }); + } + }} + onEdgeMouseEnter={(_event, edge) => setHoveredEdgeId(edge.id)} + onEdgeMouseMove={(event, edge) => { + setHoveredEdgeId(edge.id); + const data = edge.data; + const [_, laneAId, laneBId] = edge.id.split(":"); + if (data?.edgeType === "risk") { + setEdgeHover({ + x: event.clientX + 12, + y: event.clientY + 12, + label: `${data.riskLevel ?? "unknown"} · ${data.overlapCount ?? 0} files${data.stale ? " · stale" : ""}` + }); + return; + } + if (data?.edgeType === "stack" && laneAId && laneBId) { + setEdgeHover({ + x: event.clientX + 12, + y: event.clientY + 12, + label: `${laneById.get(laneAId)?.name ?? laneAId} → ${laneById.get(laneBId)?.name ?? laneBId}` + }); + return; + } + setEdgeHover(null); + }} + onEdgeMouseLeave={() => { + setEdgeHover(null); + setHoveredEdgeId(null); + }} + fitView + panOnDrag + zoomOnScroll + zoomOnPinch + multiSelectionKeyCode={["Shift"]} + selectionOnDrag + nodeDragThreshold={5} + minZoom={0.25} + maxZoom={2} + > + + + + void reactFlow.fitView({ duration: 500, padding: 0.2 })}> + + + + + {loadingRisk ? ( +
+ Loading risk data… +
+ ) : batchProgress ? ( +
+ Computing {batchProgress.completedPairs}/{batchProgress.totalPairs} pairs… +
+ ) : null} +
+ {lanes.length === 1 && lanes[0]?.laneType === "primary" ? ( + +
+ Create a worktree lane to see your topology. +
+
+ ) : null} + {dropPreview ? ( + +
+
{dropPreview.message}
+
{dropPreview.detail}
+
+
+ ) : null} + +
+
Environment Legend
+ {lanesForLegend.length === 0 ? ( +
No custom node colors yet.
+ ) : ( +
+ {lanesForLegend.map(([color, laneName]) => ( +
+ + {laneName} +
+ ))} +
+ )} +
+
+ + {dragTrailScreen ? ( + + + + ) : null} +
+ + {contextMenu ? ( +
setContextMenu(null)} + > + {(() => { + const lane = laneById.get(contextMenu.laneId); + const isPrimary = lane?.laneType === "primary"; + const hasParent = Boolean(lane?.parentLaneId); + const hasChildren = (lane?.childCount ?? 0) > 0; + const isCollapsed = collapsedLaneIds.has(contextMenu.laneId); + const items: Array<{ key: string; label: string; disabled?: boolean; reason?: string }> = [ + { key: "open", label: "Open" }, + { key: "create-child", label: "Create Child" }, + { key: "archive", label: "Archive", disabled: isPrimary, reason: "Primary lane cannot be archived." }, + { key: "delete", label: "Delete", disabled: isPrimary, reason: "Primary lane cannot be deleted." }, + { key: "restack", label: "Rebase", disabled: !hasParent, reason: "Rebase is only available for child lanes." }, + { key: "push", label: "Push" }, + { key: "fetch", label: "Fetch" }, + { key: "sync", label: "Pull" }, + { key: "reparent", label: "Reparent", disabled: isPrimary, reason: "Primary lane cannot be reparented." }, + { key: "rename", label: "Rename" }, + { key: "customize", label: "Customize Appearance" }, + { + key: isCollapsed ? "expand" : "collapse", + label: isCollapsed ? "Expand Stack" : "Collapse Stack", + disabled: !isCollapsed && !hasChildren, + reason: "No child lanes to collapse." + } + ]; + return items.map((item) => ( + + )); + })()} +
+ ) : null} + + {appearanceEditor ? ( +
+
Customize Appearance
+
Color
+
+ {COLOR_PALETTE.map((color) => ( +
+
Icon
+
+ {ICON_OPTIONS.map((option) => ( + + ))} +
+
Tags
+
+ {appearanceEditor.tags.map((tag) => ( + + {tag} + + + ))} +
+
+ setAppearanceEditor((prev) => (prev ? { ...prev, newTag: event.target.value } : prev))} + className="h-7 flex-1 rounded border border-border bg-bg px-2 text-xs outline-none" + placeholder="new tag" + /> + +
+
+ + +
+
+ ) : null} + + {reparentDialog ? ( +
+
+
Confirm Lane Drop
+ {reparentDialog.integratePlan ? ( +
+ + +
+ ) : null} +
+ {reparentDialog.actionMode === "integrate" + ? "Integrate keeps stack ancestry unchanged and brings source lane commits into the target lane." + : "Reparent changes stack ancestry. ADE rebases selected lane commits onto the target parent branch."} +
+ {reparentDialog.actionMode === "integrate" && reparentDialog.integratePlan ? ( +
+
{reparentDialog.integratePlan.summary}
+
{reparentDialog.integratePlan.detail}
+
+ ) : ( + <> +
+ Target parent: {laneById.get(reparentDialog.targetLaneId)?.name ?? reparentDialog.targetLaneId} +
+
+ {reparentDialog.laneIds.length === 1 ? ( +
+ {laneById.get(reparentDialog.laneIds[0]!)?.name ?? reparentDialog.laneIds[0]} → {laneById.get(reparentDialog.targetLaneId)?.name ?? reparentDialog.targetLaneId} +
+ ) : ( +
+ {reparentDialog.laneIds.map((laneId) => ( +
+ {laneById.get(laneId)?.name ?? laneId} → {laneById.get(reparentDialog.targetLaneId)?.name ?? reparentDialog.targetLaneId} +
+ ))} +
+ )} +
+ + )} + {reparentDialog.overlapFiles.length > 0 ? ( +
+ ⚠ {reparentDialog.overlapFiles.length} overlapping files detected. +
+ ) : ( +
+ No overlapping files detected. +
+ )} +
+ {reparentDialog.overlapFiles.length === 0 + ? "No overlaps." + : reparentDialog.overlapFiles.slice(0, 12).map((file) =>
{file}
)} +
+
+ {reparentDialog.actionMode === "integrate" + ? "If merge/rebase conflicts occur, resolve them in the target lane." + : "If conflicts occur during rebase, resolve them in the target lane context."} + {reparentDialog.actionMode === "reparent" && laneById.get(reparentDialog.targetLaneId)?.laneType === "primary" + ? " Target is Primary: lane will now be based directly on Primary." + : ""} +
+ {reparentDialog.preview ? ( +
+
Preview outcome: {reparentDialog.preview.outcome}
+
+ files changed: {reparentDialog.preview.diffStat.filesChanged} · conflicts: {reparentDialog.preview.conflictingFiles.length} +
+
+ ) : null} +
+ + + +
+
+
+ ) : null} + + {selectedLaneIds.length > 1 ? ( +
+
+
{selectedLaneIds.length} lanes selected
+
+ + + + + +
+ {batchStatus ? ( +
+
+ {batchOperationLabel(batchStatus.operation)} lane {Math.min(batchStatus.steps.length, batchStatus.activeIndex + 1)}/{batchStatus.steps.length}: {batchStatus.steps[batchStatus.activeIndex]?.laneName ?? "pending"} +
+
+
step.status === "done" || step.status === "failed" || step.status === "skipped").length / Math.max(1, batchStatus.steps.length)) * 100}%` }} + /> +
+
+ {batchStatus.steps.map((step) => ( +
+ {step.laneName} + + {step.status === "running" ? "⟳ running" : step.status === "done" ? "✓ done" : step.status === "failed" ? "✗ failed" : step.status === "skipped" ? "⚠ skipped" : "⏳ pending"} + {step.error ? ` · ${step.error}` : ""} + +
+ ))} +
+ {batchStatus.summary ?
{batchStatus.summary}
: null} +
+ ) : null} +
+
+ ) : null} + + {batch?.truncated ? ( +
+
+
+ Too many lanes for automatic risk assessment. Showing {batch.comparedLaneIds?.length ?? batch.maxAutoLanes ?? 15} of {batch.totalLanes ?? lanes.length} lanes. +
+ +
+
+ ) : null} + + {errorBanner ? ( +
+
+
+ + {errorBanner} +
+
+ + +
+
+
+ ) : null} + + {undoToast ? ( +
+
{undoToast.message}
+
+ + +
+
+ ) : null} + + {edgeSimulation ? ( +
+
+
Merge Simulation
+ +
+
+ {(laneById.get(edgeSimulation.laneAId)?.name ?? edgeSimulation.laneAId)} → {(laneById.get(edgeSimulation.laneBId)?.name ?? edgeSimulation.laneBId)} +
+ {edgeSimulation.loading ? ( +
+
+
Running merge simulation…
+
+ ) : edgeSimulation.error ? ( +
+
Simulation failed
+
{edgeSimulation.error}
+
+ ) : edgeSimulation.result ? ( +
+
+
Outcome: {edgeSimulation.result.outcome}
+
+ files changed: {edgeSimulation.result.diffStat.filesChanged} · insertions: {edgeSimulation.result.diffStat.insertions} · deletions: {edgeSimulation.result.diffStat.deletions} +
+
+
+ {edgeSimulation.result.conflictingFiles.length === 0 ? ( +
No conflicting files.
+ ) : ( + edgeSimulation.result.conflictingFiles.map((file) => ( +
+ {file.path} +
+ )) + )} +
+
+ ) : null} +
+ ) : null} + + {nodeTooltip && hoveredTooltipLane ? ( +
+
{hoveredTooltipLane.name}
+
{hoveredTooltipLane.branchRef}
+
dirty changes: {hoveredTooltipLane.status.dirty ? "yes" : "no"}
+
last activity: {toRelativeTime(lastActivityByLaneId[hoveredTooltipLane.id] ?? null)}
+
+ ) : null} + + {textPrompt ? ( +
+
+
{textPrompt.title}
+ {textPrompt.message ? ( +
+ {textPrompt.message} +
+ ) : null} + { + const nextValue = event.target.value; + setTextPrompt((prev) => (prev ? { ...prev, value: nextValue } : prev)); + if (textPromptError) setTextPromptError(null); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelTextPrompt(); + } else if (event.key === "Enter") { + event.preventDefault(); + submitTextPrompt(); + } + }} + placeholder={textPrompt.placeholder} + className="mt-3 h-9 w-full rounded border border-border bg-bg px-2 text-sm outline-none focus:border-accent" + /> + {textPromptError ?
{textPromptError}
: null} +
+ + +
+
+
+ ) : null} + + {edgeHover ? ( +
+ {edgeHover.label} +
+ ) : null} +
+ ); +} + +export function WorkspaceGraphPage() { + return ( + + + + ); +} diff --git a/apps/desktop/src/renderer/components/lanes/LaneDetail.tsx b/apps/desktop/src/renderer/components/lanes/LaneDetail.tsx index ded7cca20..5937f0d1d 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDetail.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDetail.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { ArrowDownToLine, ArrowRight, Columns, GitMerge, MoreHorizontal, RefreshCw, Save, Undo2, Upload } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ArrowDownToLine, ArrowRight, Columns, GitMerge, Layers3, MoreHorizontal, RefreshCw, Save, Undo2, Upload } from "lucide-react"; import * as Dialog from "@radix-ui/react-dialog"; import { EmptyState } from "../ui/EmptyState"; import { useAppStore } from "../../state/appStore"; @@ -23,6 +23,16 @@ function formatTs(ts: string): string { return date.toLocaleString(); } +type LaneTextPromptState = { + title: string; + message?: string; + placeholder?: string; + value: string; + confirmLabel: string; + validate?: (value: string) => string | null; + resolve: (value: string | null) => void; +}; + export function LaneDetail({ overrideLaneId, isPrimary, @@ -36,9 +46,28 @@ export function LaneDetail({ const laneId = overrideLaneId ?? globalLaneId; const lanes = useAppStore((s) => s.lanes); const refreshLanes = useAppStore((s) => s.refreshLanes); + const selectLane = useAppStore((s) => s.selectLane); const lane = useMemo(() => lanes.find((entry) => entry.id === laneId) ?? null, [lanes, laneId]); const laneName = lane?.name ?? null; + const parentLane = useMemo( + () => (lane?.parentLaneId ? lanes.find((entry) => entry.id === lane.parentLaneId) ?? null : null), + [lane, lanes] + ); + const childLanes = useMemo( + () => + lane + ? lanes + .filter((entry) => entry.parentLaneId === lane.id) + .sort((a, b) => { + const aTs = Date.parse(a.createdAt); + const bTs = Date.parse(b.createdAt); + if (!Number.isNaN(aTs) && !Number.isNaN(bTs) && aTs !== bTs) return aTs - bTs; + return a.name.localeCompare(b.name); + }) + : [], + [lane, lanes] + ); const [loading, setLoading] = useState(false); const [changes, setChanges] = useState({ unstaged: [], staged: [] }); @@ -55,9 +84,59 @@ export function LaneDetail({ const [busyAction, setBusyAction] = useState(null); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); + const [textPrompt, setTextPrompt] = useState(null); + const [textPromptError, setTextPromptError] = useState(null); const diffRef = useRef(null); + const requestTextInput = useCallback( + (args: { + title: string; + message?: string; + placeholder?: string; + defaultValue?: string; + confirmLabel?: string; + validate?: (value: string) => string | null; + }): Promise => { + return new Promise((resolve) => { + setTextPromptError(null); + setTextPrompt({ + title: args.title, + message: args.message, + placeholder: args.placeholder, + value: args.defaultValue ?? "", + confirmLabel: args.confirmLabel ?? "Confirm", + validate: args.validate, + resolve + }); + }); + }, + [] + ); + + const cancelTextPrompt = useCallback(() => { + setTextPrompt((prev) => { + if (prev) prev.resolve(null); + return null; + }); + setTextPromptError(null); + }, []); + + const submitTextPrompt = useCallback(() => { + setTextPrompt((prev) => { + if (!prev) return prev; + const value = prev.value.trim(); + const validationError = prev.validate?.(value) ?? null; + if (validationError) { + setTextPromptError(validationError); + return prev; + } + setTextPromptError(null); + prev.resolve(value); + return null; + }); + }, []); + const refreshChanges = async () => { if (!laneId) return; setLoading(true); @@ -102,7 +181,9 @@ export function LaneDetail({ setNotice(`${actionName} completed`); setTimeout(() => setNotice(null), 3000); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + if (message === "__ade_cancelled__") return; + setError(message); } finally { setBusyAction(null); } @@ -227,6 +308,28 @@ export function LaneDetail({ + {lane?.parentLaneId ? ( + <> +
+ + + ) : null}
+ @@ -275,9 +390,52 @@ export function LaneDetail({ } /> + {lane && (parentLane || childLanes.length > 0) ? ( +
+
+ {parentLane ? ( + <> + Parent: + + + ) : ( + Stack root + )} + {childLanes.length > 0 ? ( + <> + Children: + {childLanes.map((child) => ( + + ))} + + ) : null} +
+
+ ) : null} + {lane ? ( +
+ Rebase: rebase lane commits onto parent lane head. + | + Pull ({syncMode}): bring {lane.baseRef} into this lane. +
+ ) : null} +
{/* Top Section: 2 Columns for Staging */} -
+
{/* Unstaged Column */}
{renderFileList("Unstaged", changes.unstaged, "Stage", stageFile, stageAll)} @@ -330,9 +488,13 @@ export function LaneDetail({ {/* Bottom Section: Diff View */}
{!laneId ? ( - +
+ +
) : !selectedPath ? ( - +
+ +
) : !diff ? (
Loading diff...
) : ( @@ -390,8 +552,12 @@ export function LaneDetail({ if (!laneId) return; setGitActionsOpen(false); runAction("stash push", async () => { - const msg = window.prompt("Stash message?"); - if (msg !== null) await window.ade.git.stashPush({ laneId, message: msg || undefined }); + const msg = await requestTextInput({ + title: "Stash message", + placeholder: "optional" + }); + if (msg == null) throw new Error("__ade_cancelled__"); + await window.ade.git.stashPush({ laneId, message: msg || undefined }); }); }} > @@ -424,9 +590,13 @@ export function LaneDetail({ onClick={() => { if (!laneId || recentCommits.length === 0) return; setGitActionsOpen(false); - const sha = window.prompt("Enter commit SHA to revert (default: HEAD)", recentCommits[0]!.sha); - if (!sha) return; - runAction(`revert ${sha.slice(0, 7)}`, async () => { + runAction("revert commit", async () => { + const sha = await requestTextInput({ + title: "Commit SHA to revert", + defaultValue: recentCommits[0]!.sha, + validate: (value) => (value ? null : "Commit SHA is required") + }); + if (!sha) throw new Error("__ade_cancelled__"); await window.ade.git.revertCommit({ laneId, commitSha: sha }); }); }} @@ -439,9 +609,12 @@ export function LaneDetail({ onClick={() => { if (!laneId) return; setGitActionsOpen(false); - const sha = window.prompt("Enter commit SHA to cherry-pick"); - if (!sha) return; - runAction(`cherry-pick ${sha.slice(0, 7)}`, async () => { + runAction("cherry-pick commit", async () => { + const sha = await requestTextInput({ + title: "Commit SHA to cherry-pick", + validate: (value) => (value ? null : "Commit SHA is required") + }); + if (!sha) throw new Error("__ade_cancelled__"); await window.ade.git.cherryPickCommit({ laneId, commitSha: sha }); }); }} @@ -455,6 +628,44 @@ export function LaneDetail({ + {textPrompt ? ( +
+
+
{textPrompt.title}
+ {textPrompt.message ?
{textPrompt.message}
: null} + { + const nextValue = event.target.value; + setTextPrompt((prev) => (prev ? { ...prev, value: nextValue } : prev)); + if (textPromptError) setTextPromptError(null); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelTextPrompt(); + } else if (event.key === "Enter") { + event.preventDefault(); + submitTextPrompt(); + } + }} + placeholder={textPrompt.placeholder} + className="mt-3 h-9 w-full rounded border border-border bg-bg px-2 text-sm outline-none focus:border-accent" + /> + {textPromptError ?
{textPromptError}
: null} +
+ + +
+
+
+ ) : null} + {/* Footer Info / Status Bar (optional, maybe specific lane errors/notices) */} {(notice || error || busyAction) && (
diff --git a/apps/desktop/src/renderer/components/lanes/LaneInspector.tsx b/apps/desktop/src/renderer/components/lanes/LaneInspector.tsx index 9b409ef9a..01718e2e5 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneInspector.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneInspector.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo, useState } from "react"; import * as Tabs from "@radix-ui/react-tabs"; import { EmptyState } from "../ui/EmptyState"; import { cn } from "../ui/cn"; @@ -6,6 +6,8 @@ import { PaneHeader } from "../ui/PaneHeader"; import { LaneTerminalsPanel } from "./LaneTerminalsPanel"; import { PackViewer } from "../packs/PackViewer"; import { useAppStore } from "../../state/appStore"; +import { Button } from "../ui/Button"; +import { Layers3 } from "lucide-react"; const tabTrigger = "px-2.5 py-2 text-xs font-semibold text-muted-fg data-[state=active]:text-fg data-[state=active]:border-b-2 data-[state=active]:border-accent"; @@ -18,7 +20,44 @@ export function LaneInspector({ hideHeader?: boolean; } = {}) { const selectedLaneId = useAppStore((s) => s.selectedLaneId); + const lanes = useAppStore((s) => s.lanes); + const selectLane = useAppStore((s) => s.selectLane); + const refreshLanes = useAppStore((s) => s.refreshLanes); const laneId = overrideLaneId ?? selectedLaneId; + const lane = useMemo(() => lanes.find((entry) => entry.id === laneId) ?? null, [lanes, laneId]); + const parentLane = useMemo( + () => (lane?.parentLaneId ? lanes.find((entry) => entry.id === lane.parentLaneId) ?? null : null), + [lane, lanes] + ); + const childLanes = useMemo( + () => lanes.filter((entry) => (lane ? entry.parentLaneId === lane.id : false)), + [lanes, lane] + ); + const [restackBusy, setRestackBusy] = useState(false); + const [restackError, setRestackError] = useState(null); + const [reparentBusy, setReparentBusy] = useState(false); + const [reparentError, setReparentError] = useState(null); + const [reparentTargetId, setReparentTargetId] = useState(""); + + const descendantIds = useMemo(() => { + if (!lane) return new Set(); + const out = new Set(); + const queue = [lane.id]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const child of lanes.filter((entry) => entry.parentLaneId === current)) { + if (out.has(child.id)) continue; + out.add(child.id); + queue.push(child.id); + } + } + return out; + }, [lane, lanes]); + + const reparentOptions = useMemo(() => { + if (!lane) return []; + return lanes.filter((entry) => entry.id !== lane.id && !descendantIds.has(entry.id)); + }, [descendantIds, lane, lanes]); return (
@@ -31,6 +70,9 @@ export function LaneInspector({ Packs + + Stack + Conflicts @@ -45,11 +87,134 @@ export function LaneInspector({ + + {!lane ? ( + + ) : ( +
+
+
Depth
+
{lane.stackDepth}
+
+
+
Parent
+ {parentLane ? ( + + ) : ( +
None (root lane)
+ )} +
+
+
Children
+ {childLanes.length === 0 ? ( +
No child lanes
+ ) : ( +
+ {childLanes.map((child) => ( + + ))} +
+ )} +
+ {lane.parentLaneId ? ( +
+ + {restackError ?
{restackError}
: null} +
+ ) : null} +
+
Reparent
+
+ + +
+ {reparentError ?
{reparentError}
: null} +
+
+ )} +
- +
+ +
- +
+ +
diff --git a/apps/desktop/src/renderer/components/lanes/LaneList.tsx b/apps/desktop/src/renderer/components/lanes/LaneList.tsx index af825e530..40b086260 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneList.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneList.tsx @@ -5,6 +5,56 @@ import { PaneHeader } from "../ui/PaneHeader"; import { Button } from "../ui/Button"; import { RefreshCw } from "lucide-react"; import { EmptyState } from "../ui/EmptyState"; +import { cn } from "../ui/cn"; +import type { LaneSummary } from "../../../shared/types"; + +function sortLanesForStackGraph(lanes: LaneSummary[]): LaneSummary[] { + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const childrenByParent = new Map(); + const roots: LaneSummary[] = []; + + for (const lane of lanes) { + if (!lane.parentLaneId || !laneById.has(lane.parentLaneId)) { + roots.push(lane); + continue; + } + const children = childrenByParent.get(lane.parentLaneId) ?? []; + children.push(lane); + childrenByParent.set(lane.parentLaneId, children); + } + + const byCreatedAsc = (a: LaneSummary, b: LaneSummary) => { + const aTs = Date.parse(a.createdAt); + const bTs = Date.parse(b.createdAt); + if (!Number.isNaN(aTs) && !Number.isNaN(bTs) && aTs !== bTs) return aTs - bTs; + return a.name.localeCompare(b.name); + }; + + const orderedRoots = [...roots].sort((a, b) => { + const aPrimary = a.laneType === "primary" ? 1 : 0; + const bPrimary = b.laneType === "primary" ? 1 : 0; + if (aPrimary !== bPrimary) return bPrimary - aPrimary; + return byCreatedAsc(a, b); + }); + for (const [parentId, children] of childrenByParent.entries()) { + childrenByParent.set(parentId, [...children].sort(byCreatedAsc)); + } + + const out: LaneSummary[] = []; + const visit = (lane: LaneSummary) => { + out.push(lane); + for (const child of childrenByParent.get(lane.id) ?? []) { + visit(child); + } + }; + for (const root of orderedRoots) { + visit(root); + } + + const seen = new Set(out.map((lane) => lane.id)); + const dangling = lanes.filter((lane) => !seen.has(lane.id)).sort(byCreatedAsc); + return out.concat(dangling); +} export function LaneList({ selectedLaneIds, @@ -29,17 +79,35 @@ export function LaneList({ const selectedIdSet = new Set(activeIds); const effectivePrimaryId = primaryLaneId ?? storeSelectedLaneId; const effectiveFilter = filterQuery ?? localFilter; + const stackOrderedLanes = useMemo(() => sortLanesForStackGraph(lanes), [lanes]); const visibleLanes = useMemo(() => { const needle = effectiveFilter.trim().toLowerCase(); - if (!needle) return lanes; - return lanes.filter((lane) => { + if (!needle) return stackOrderedLanes; + return stackOrderedLanes.filter((lane) => { const name = lane.name.toLowerCase(); const branch = lane.branchRef.toLowerCase(); const type = lane.laneType.toLowerCase(); return name.includes(needle) || branch.includes(needle) || type.includes(needle); }); - }, [lanes, effectiveFilter]); + }, [stackOrderedLanes, effectiveFilter]); + + const isLastSiblingByLaneId = useMemo(() => { + const siblingsByParent = new Map(); + for (const lane of visibleLanes) { + if (!lane.parentLaneId) continue; + const list = siblingsByParent.get(lane.parentLaneId) ?? []; + list.push(lane.id); + siblingsByParent.set(lane.parentLaneId, list); + } + const out = new Map(); + for (const siblingIds of siblingsByParent.values()) { + siblingIds.forEach((id, index) => { + out.set(id, index === siblingIds.length - 1); + }); + } + return out; + }, [visibleLanes]); return (
@@ -92,6 +160,7 @@ export function LaneList({ lane={lane} selected={selectedIdSet.has(lane.id)} primary={lane.id === effectivePrimaryId} + isLastSibling={isLastSiblingByLaneId.get(lane.id) ?? false} onSelect={(args) => { if (onLaneSelect) { onLaneSelect(lane.id, args); @@ -103,8 +172,54 @@ export function LaneList({ ))}
)} -
- Stack graph (placeholder) +
+
Stack Graph
+
+ {visibleLanes.map((lane) => ( + + ))} +
diff --git a/apps/desktop/src/renderer/components/lanes/LaneRow.tsx b/apps/desktop/src/renderer/components/lanes/LaneRow.tsx index c5880b5ae..ec326f78f 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneRow.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneRow.tsx @@ -1,22 +1,48 @@ -import React, { useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import * as Dialog from "@radix-ui/react-dialog"; -import { AlertTriangle, Archive, ExternalLink, GitBranch, Pencil, TerminalSquare, Trash2 } from "lucide-react"; -import type { LaneSummary } from "../../../shared/types"; +import { AlertTriangle, Archive, ExternalLink, GitBranch, Layers3, Pencil, TerminalSquare, Trash2 } from "lucide-react"; +import type { ConflictChip, ConflictStatus, LaneSummary } from "../../../shared/types"; import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; import { useNavigate } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; +function conflictDotClass(status: ConflictStatus["status"] | null | undefined): string { + if (status === "conflict-active") return "bg-red-600"; + if (status === "conflict-predicted") return "bg-orange-500"; + if (status === "behind-base") return "bg-amber-500"; + if (status === "merge-ready") return "bg-emerald-500"; + return "bg-muted-fg"; +} + +function conflictSeverity(status: ConflictStatus["status"] | null | undefined): number { + if (status === "conflict-active") return 5; + if (status === "conflict-predicted") return 4; + if (status === "behind-base") return 3; + if (status === "unknown") return 2; + return 1; +} + +function chipText(kind: ConflictChip["kind"]): string { + return kind === "new-overlap" ? "new overlap" : "high risk"; +} + export function LaneRow({ lane, selected, primary, - onSelect + onSelect, + isLastSibling, + conflictStatus, + conflictChips }: { lane: LaneSummary; selected: boolean; primary?: boolean; onSelect: (args: { extend: boolean }) => void; + isLastSibling?: boolean; + conflictStatus?: ConflictStatus | null; + conflictChips?: ConflictChip[]; }) { const navigate = useNavigate(); const focusSession = useAppStore((s) => s.focusSession); @@ -29,13 +55,36 @@ export function LaneRow({ const [draftName, setDraftName] = useState(lane.name); const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [deleteForce, setDeleteForce] = useState(false); + const [statusAnimClass, setStatusAnimClass] = useState(null); + const previousStatusRef = useRef(conflictStatus?.status ?? null); const [deleteError, setDeleteError] = useState(null); const [deleteBusy, setDeleteBusy] = useState(false); const isPrimaryLane = lane.laneType === "primary"; + const stackIndentPx = lane.stackDepth * 16; + const connectorLeft = 8 + Math.max(0, lane.stackDepth - 1) * 16; const confirmationPhrase = `delete ${lane.name}`; + useEffect(() => { + const prev = previousStatusRef.current; + const next = conflictStatus?.status ?? null; + previousStatusRef.current = next; + if (!prev || !next || prev === next) return; + const nextSeverity = conflictSeverity(next); + const prevSeverity = conflictSeverity(prev); + if (nextSeverity > prevSeverity) { + setStatusAnimClass("ade-conflict-badge-worse"); + const timer = window.setTimeout(() => setStatusAnimClass(null), 1900); + return () => window.clearTimeout(timer); + } + if (nextSeverity < prevSeverity) { + setStatusAnimClass("ade-conflict-badge-better"); + const timer = window.setTimeout(() => setStatusAnimClass(null), 420); + return () => window.clearTimeout(timer); + } + }, [conflictStatus?.status]); + return (
onSelect({ extend: event.shiftKey })} role="button" tabIndex={0} @@ -50,17 +100,60 @@ export function LaneRow({ if (e.key === "Enter" || e.key === " ") onSelect({ extend: e.shiftKey }); }} > + {lane.parentLaneId ? ( + <> +
+
+ + ) : null}
{lane.name} {lane.laneType} + {lane.parentLaneId ? ( + + + d{lane.stackDepth} + + ) : null} {isPrimaryLane ? home : null} + + + {conflictStatus?.status ?? "unknown"} +
{lane.description ? (
{lane.description}
) : null} + {conflictChips && conflictChips.length > 0 ? ( +
+ {conflictChips.slice(0, 2).map((chip, index) => ( + + {chipText(chip.kind)} + + ))} +
+ ) : null}
@@ -322,7 +415,7 @@ export function LaneRow({
- Sync + {lane.parentLaneId ? "Vs Parent" : "Pull"} 0 || lane.status.behind > 0 ? "text-accent" : "text-fg")}> {lane.status.ahead}↑ {lane.status.behind}↓ diff --git a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx index 6f794cb76..2cd2dad82 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx @@ -160,7 +160,11 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string }, [focusSession, focusedSessionId, refresh, refreshLanes]); if (!laneId) { - return ; + return ( +
+ +
+ ); } const current = sessions.find((s) => s.id === focusedSessionId) ?? sessions[0] ?? null; @@ -243,7 +247,9 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string
{sessions.length === 0 ? ( - +
+ +
) : viewMode === "tabs" ? ( (lanes: T[]): T[] { return [...lanes].sort((a, b) => { @@ -26,6 +26,48 @@ function sortLanesForTabs(lan }); } +function sortLanesForStackGraph(lanes: LaneSummary[]): LaneSummary[] { + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const childrenByParent = new Map(); + const roots: LaneSummary[] = []; + + for (const lane of lanes) { + if (!lane.parentLaneId || !laneById.has(lane.parentLaneId)) { + roots.push(lane); + continue; + } + const children = childrenByParent.get(lane.parentLaneId) ?? []; + children.push(lane); + childrenByParent.set(lane.parentLaneId, children); + } + + const byCreatedAsc = (a: LaneSummary, b: LaneSummary) => { + const aTs = Date.parse(a.createdAt); + const bTs = Date.parse(b.createdAt); + if (!Number.isNaN(aTs) && !Number.isNaN(bTs) && aTs !== bTs) return aTs - bTs; + return a.name.localeCompare(b.name); + }; + roots.sort((a, b) => { + const aPrimary = a.laneType === "primary" ? 1 : 0; + const bPrimary = b.laneType === "primary" ? 1 : 0; + if (aPrimary !== bPrimary) return bPrimary - aPrimary; + return byCreatedAsc(a, b); + }); + for (const [parentId, children] of childrenByParent.entries()) { + childrenByParent.set(parentId, [...children].sort(byCreatedAsc)); + } + + const out: LaneSummary[] = []; + const visit = (lane: LaneSummary) => { + out.push(lane); + for (const child of childrenByParent.get(lane.id) ?? []) visit(child); + }; + for (const root of roots) visit(root); + + const seen = new Set(out.map((lane) => lane.id)); + return out.concat(lanes.filter((lane) => !seen.has(lane.id)).sort(byCreatedAsc)); +} + function mergeUnique(...lists: string[][]): string[] { const seen = new Set(); const out: string[] = []; @@ -102,6 +144,18 @@ function laneMatchesFilter(lane: LaneSummary, isPinned: boolean, query: string): return tokens.every((token) => matchesLaneFilterToken(lane, isPinned, token)); } +function conflictDotClass(status: ConflictStatus["status"] | undefined): string { + if (status === "conflict-active") return "bg-red-600"; + if (status === "conflict-predicted") return "bg-orange-500"; + if (status === "behind-base") return "bg-amber-500"; + if (status === "merge-ready") return "bg-emerald-500"; + return "bg-muted-fg"; +} + +function chipLabel(kind: ConflictChip["kind"]): string { + return kind === "high-risk" ? "high risk" : "new overlap"; +} + export function LanesPage() { const [params] = useSearchParams(); const selectLane = useAppStore((s) => s.selectLane); @@ -120,6 +174,9 @@ export function LanesPage() { const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [laneActionBusy, setLaneActionBusy] = useState(false); const [laneActionError, setLaneActionError] = useState(null); + const [conflictStatusByLane, setConflictStatusByLane] = useState>({}); + const [conflictChipsByLane, setConflictChipsByLane] = useState>({}); + const chipTimersRef = useRef>(new Map()); const sortedLanes = useMemo(() => sortLanesForTabs(lanes), [lanes]); const lanesById = useMemo(() => new Map(sortedLanes.map((lane) => [lane.id, lane])), [sortedLanes]); @@ -127,16 +184,112 @@ export function LanesPage() { const filteredLanes = useMemo(() => { return sortedLanes.filter((lane) => laneMatchesFilter(lane, pinnedLaneIds.has(lane.id), laneFilter)); }, [sortedLanes, laneFilter, pinnedLaneIds]); + const stackGraphLanes = useMemo(() => sortLanesForStackGraph(filteredLanes), [filteredLanes]); + const stackGraphIsLastSiblingByLaneId = useMemo(() => { + const siblingsByParent = new Map(); + for (const lane of stackGraphLanes) { + if (!lane.parentLaneId) continue; + const list = siblingsByParent.get(lane.parentLaneId) ?? []; + list.push(lane.id); + siblingsByParent.set(lane.parentLaneId, list); + } + const out = new Map(); + for (const siblingIds of siblingsByParent.values()) { + siblingIds.forEach((id, index) => { + out.set(id, index === siblingIds.length - 1); + }); + } + return out; + }, [stackGraphLanes]); const filteredLaneIds = useMemo(() => filteredLanes.map((lane) => lane.id), [filteredLanes]); + const loadConflictStatuses = useCallback(async () => { + try { + const assessment = await window.ade.conflicts.getBatchAssessment(); + const next: Record = {}; + for (const status of assessment.lanes) { + next[status.laneId] = status; + } + setConflictStatusByLane(next); + } catch { + // best effort: lane rendering should still work without conflict data + } + }, []); + + const pushConflictChips = useCallback((chips: ConflictChip[]) => { + if (chips.length === 0) return; + const now = Date.now(); + setConflictChipsByLane((prev) => { + const next: Record = { ...prev }; + for (const chip of chips) { + const laneList = next[chip.laneId] ? [...next[chip.laneId]!] : []; + laneList.unshift(chip); + next[chip.laneId] = laneList.slice(0, 3); + } + return next; + }); + + for (const chip of chips) { + const key = `${chip.laneId}:${chip.peerId ?? "base"}:${chip.kind}`; + const existing = chipTimersRef.current.get(key); + if (existing) window.clearTimeout(existing); + const timer = window.setTimeout(() => { + setConflictChipsByLane((prev) => { + const laneChips = prev[chip.laneId] ?? []; + const filtered = laneChips.filter((entry) => { + return !( + entry.kind === chip.kind && + entry.peerId === chip.peerId && + entry.overlapCount === chip.overlapCount + ); + }); + if (filtered.length === laneChips.length) return prev; + return { + ...prev, + [chip.laneId]: filtered + }; + }); + chipTimersRef.current.delete(key); + }, Math.max(8_000, 12_000 - (Date.now() - now))); + chipTimersRef.current.set(key, timer); + } + }, []); + useEffect(() => { const laneId = params.get("laneId"); const sessionId = params.get("sessionId"); - if (laneId) selectLane(laneId); + if (laneId) { + selectLane(laneId); + if (params.get("focus") === "single") { + setActiveLaneIds([laneId]); + } + } if (sessionId) focusSession(sessionId); }, [params, selectLane, focusSession]); + useEffect(() => { + void loadConflictStatuses(); + }, [loadConflictStatuses, lanes.length]); + + useEffect(() => { + const unsubscribe = window.ade.conflicts.onEvent((event) => { + if (event.type !== "prediction-complete") return; + void loadConflictStatuses(); + pushConflictChips(event.chips); + }); + return unsubscribe; + }, [loadConflictStatuses, pushConflictChips]); + + useEffect(() => { + return () => { + for (const timer of chipTimersRef.current.values()) { + window.clearTimeout(timer); + } + chipTimersRef.current.clear(); + }; + }, []); + useEffect(() => { setPinnedLaneIds((prev) => { const next = new Set(); @@ -446,6 +599,8 @@ export function LanesPage() { const isPrimary = lane.laneType === "primary"; const isPinned = pinnedLaneIds.has(lane.id); const closable = isVisible && visibleLaneIds.length > 1 && !isPinned; + const conflictStatus = conflictStatusByLane[lane.id]; + const chips = conflictChipsByLane[lane.id] ?? []; return ( + ))} +
+
+ {visibleLaneIds.length === 0 ? (
- - - + + + @@ -533,7 +750,7 @@ export function LanesPage() {
- + diff --git a/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx b/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx index 30feb9638..6fa216370 100644 --- a/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx +++ b/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx @@ -13,8 +13,15 @@ async function loadMonaco(): Promise { monacoInit = (async () => { // Configure the base editor worker (good enough for plain text + basic languages). const EditorWorker = (await import("monaco-editor/esm/vs/editor/editor.worker?worker")).default; - (self as any).MonacoEnvironment = { - getWorker: () => new EditorWorker() + const globalAny = globalThis as typeof globalThis & { + MonacoEnvironment?: { + getWorker?: (workerId: string, label: string) => Worker; + }; + }; + const existing = globalAny.MonacoEnvironment; + globalAny.MonacoEnvironment = { + ...existing, + getWorker: existing?.getWorker ?? (() => new EditorWorker()) }; return await import("monaco-editor"); @@ -23,6 +30,28 @@ async function loadMonaco(): Promise { return await monacoInit; } +function disposeDiffModels(editor: import("monaco-editor").editor.IStandaloneDiffEditor | null, models: { + original: import("monaco-editor").editor.ITextModel; + modified: import("monaco-editor").editor.ITextModel; +} | null): void { + try { + editor?.setModel(null); + } catch { + // ignore + } + if (!models) return; + try { + models.original.dispose(); + } catch { + // ignore + } + try { + models.modified.dispose(); + } catch { + // ignore + } +} + export const MonacoDiffView = forwardRef( function MonacoDiffView({ diff, editable = false, className }, ref) { const containerRef = useRef(null); @@ -30,6 +59,7 @@ export const MonacoDiffView = forwardRef( null ); + const modelIdentityRef = useRef(null); const [ready, setReady] = useState(false); const [failed, setFailed] = useState(false); @@ -66,20 +96,15 @@ export const MonacoDiffView = forwardRef { cancelled = true; + disposeDiffModels(diffEditorRef.current, modelsRef.current); + modelsRef.current = null; + modelIdentityRef.current = null; try { diffEditorRef.current?.dispose(); } catch { // ignore } diffEditorRef.current = null; - - try { - modelsRef.current?.original.dispose(); - modelsRef.current?.modified.dispose(); - } catch { - // ignore - } - modelsRef.current = null; }; }, []); @@ -91,17 +116,20 @@ export const MonacoDiffView = forwardRef & { variant?: Variant; size?: Size }) { +export const Button = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes & { variant?: Variant; size?: Size } +>(function Button( + { + variant = "outline", + size = "md", + className, + ...props + }, + ref +) { const base = "inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none"; @@ -22,6 +28,5 @@ export function Button({ ghost: "bg-transparent text-fg hover:bg-muted/70 active:bg-muted/90" }; - return