From e963fe25d250b1efd0f0331c2d3c120768a9ff1f Mon Sep 17 00:00:00 2001 From: Sebastian Barfurth Date: Thu, 11 Jun 2026 10:31:25 +0000 Subject: [PATCH] Split repository.ts into multiple files. --- .vscode-test.mjs | 4 +- .vscode/extensions.json | 6 +- .vscode/launch.json | 26 +- .vscode/tasks.json | 114 +- CHANGELOG.md | 40 +- README.md | 4 +- .../jj-commit.language-configuration.json | 38 +- package.json | 3 +- src/decorationProvider.ts | 2 +- src/env.ts | 95 ++ src/fileSystemProvider.ts | 2 +- src/graphTreeView.ts | 3 +- src/graphWebview.ts | 2 +- src/jj/cli.ts | 198 +++ src/jj/parser.ts | 245 ++++ src/{ => jj}/repository.ts | 1162 +-------------- src/jj/types.ts | 56 + src/main.ts | 8 +- src/open_file.ts | 6 +- src/operationLogTreeView.ts | 3 +- src/scm/repository.ts | 297 ++++ src/scm/utils.ts | 48 + src/scm/workspace.ts | 240 ++++ src/test/all-tests.ts | 2 +- src/test/fakeeditor.test.ts | 2 +- src/test/{ => jj}/repository.test.ts | 15 +- src/webview/graph.css | 1 - src/webview/graph.html | 1257 +++++++++-------- syntaxes/jj-commit.tmLanguage.json | 120 +- 29 files changed, 2075 insertions(+), 1924 deletions(-) create mode 100644 src/env.ts create mode 100644 src/jj/cli.ts create mode 100644 src/jj/parser.ts rename src/{ => jj}/repository.ts (52%) create mode 100644 src/jj/types.ts create mode 100644 src/scm/repository.ts create mode 100644 src/scm/utils.ts create mode 100644 src/scm/workspace.ts rename src/test/{ => jj}/repository.test.ts (96%) diff --git a/.vscode-test.mjs b/.vscode-test.mjs index b62ba25f..f728f012 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,5 +1,5 @@ -import { defineConfig } from '@vscode/test-cli'; +import { defineConfig } from "@vscode/test-cli"; export default defineConfig({ - files: 'out/test/**/*.test.js', + files: "out/test/**/*.test.js", }); diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d7a3ca11..e08c0ecc 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,9 @@ { // See http://go.microsoft.com/fwlink/?LinkId=827846 // for the documentation about the extensions.json format - "recommendations": ["dbaeumer.vscode-eslint", "connor4312.esbuild-problem-matchers", "ms-vscode.extension-test-runner"] + "recommendations": [ + "dbaeumer.vscode-eslint", + "connor4312.esbuild-problem-matchers", + "ms-vscode.extension-test-runner" + ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index c42edc04..ccdb134d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,19 +3,15 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 { - "version": "0.2.0", - "configurations": [ - { - "name": "Run Extension", - "type": "extensionHost", - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" - ], - "outFiles": [ - "${workspaceFolder}/dist/**/*.js" - ], - "preLaunchTask": "${defaultBuildTask}" - } - ] + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + } + ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3cf99c37..725ab2a9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,64 +1,58 @@ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format { - "version": "2.0.0", - "tasks": [ - { - "label": "watch", - "dependsOn": [ - "npm: watch:tsc", - "npm: watch:esbuild" - ], - "presentation": { - "reveal": "never" - }, - "group": { - "kind": "build", - "isDefault": true - } - }, - { - "type": "npm", - "script": "watch:esbuild", - "group": "build", - "problemMatcher": "$esbuild-watch", - "isBackground": true, - "label": "npm: watch:esbuild", - "presentation": { - "group": "watch", - "reveal": "never" - } - }, - { - "type": "npm", - "script": "watch:tsc", - "group": "build", - "problemMatcher": "$tsc-watch", - "isBackground": true, - "label": "npm: watch:tsc", - "presentation": { - "group": "watch", - "reveal": "never" - } - }, - { - "type": "npm", - "script": "watch-tests", - "problemMatcher": "$tsc-watch", - "isBackground": true, - "presentation": { - "reveal": "never", - "group": "watchers" - }, - "group": "build" - }, - { - "label": "tasks: watch-tests", - "dependsOn": [ - "npm: watch", - "npm: watch-tests" - ], - "problemMatcher": [] - } - ] + "version": "2.0.0", + "tasks": [ + { + "label": "watch", + "dependsOn": ["npm: watch:tsc", "npm: watch:esbuild"], + "presentation": { + "reveal": "never" + }, + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "type": "npm", + "script": "watch:esbuild", + "group": "build", + "problemMatcher": "$esbuild-watch", + "isBackground": true, + "label": "npm: watch:esbuild", + "presentation": { + "group": "watch", + "reveal": "never" + } + }, + { + "type": "npm", + "script": "watch:tsc", + "group": "build", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "label": "npm: watch:tsc", + "presentation": { + "group": "watch", + "reveal": "never" + } + }, + { + "type": "npm", + "script": "watch-tests", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never", + "group": "watchers" + }, + "group": "build" + }, + { + "label": "tasks: watch-tests", + "dependsOn": ["npm: watch", "npm: watch-tests"], + "problemMatcher": [] + } + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5d7894..1e2f1b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,77 +2,77 @@ ## 0.0.11 -* Prevent switching the view to SCM view after changes. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/47 +- Prevent switching the view to SCM view after changes. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/47 **Full Changelog**: https://github.com/sbarfurth/ukemi/compare/0.0.10...0.0.11 ## 0.0.10 -* Use icons to denote change state in graph. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/45 -* Disable file tracking on custom backends. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/41 +- Use icons to denote change state in graph. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/45 +- Disable file tracking on custom backends. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/41 **Full Changelog**: https://github.com/sbarfurth/ukemi/compare/0.0.9...0.0.10 ## 0.0.9 -* Don't focus tree view after selection. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/42 -* Fix incorrect calculation of root items in graph tree view. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/43 +- Don't focus tree view after selection. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/42 +- Fix incorrect calculation of root items in graph tree view. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/43 **Full Changelog**: https://github.com/sbarfurth/ukemi/compare/0.0.8...0.0.9 ## 0.0.8 -* Prevent showing immutable parent in SCM panel. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/36 -* Provide more customization on change nodes in graph. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/37 -* Show progress indicator when checking out a revision by @dakhlopkau in https://github.com/sbarfurth/ukemi/pull/35 -* Add commit tree view. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/39 +- Prevent showing immutable parent in SCM panel. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/36 +- Provide more customization on change nodes in graph. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/37 +- Show progress indicator when checking out a revision by @dakhlopkau in https://github.com/sbarfurth/ukemi/pull/35 +- Add commit tree view. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/39 ### New Contributors -* @dakhlopkau made their first contribution in https://github.com/sbarfurth/ukemi/pull/35 +- @dakhlopkau made their first contribution in https://github.com/sbarfurth/ukemi/pull/35 **Full Changelog**: https://github.com/sbarfurth/ukemi/compare/0.0.7...0.0.8 ## 0.0.7 -* Allow configuring graph revset. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/28 -* Open working copy on right side of diff editors. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/30 -* Switch open and diff actions on SCM file resources. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/31 +- Allow configuring graph revset. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/28 +- Open working copy on right side of diff editors. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/30 +- Switch open and diff actions on SCM file resources. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/31 **Full Changelog**: https://github.com/sbarfurth/ukemi/compare/0.0.6...0.0.7 ## 0.0.6 -* add bookmarks to graph view by @sesceu in https://github.com/sbarfurth/ukemi/pull/25 -* fix missing working copy indicator by @sesceu in https://github.com/sbarfurth/ukemi/pull/26 +- add bookmarks to graph view by @sesceu in https://github.com/sbarfurth/ukemi/pull/25 +- fix missing working copy indicator by @sesceu in https://github.com/sbarfurth/ukemi/pull/26 ### New Contributors -* @sesceu made their first contribution in https://github.com/sbarfurth/ukemi/pull/25 +- @sesceu made their first contribution in https://github.com/sbarfurth/ukemi/pull/25 **Full Changelog**: https://github.com/sbarfurth/ukemi/compare/0.0.5...0.0.6 ## 0.0.5 -* *no user-visible changes* +- _no user-visible changes_ **Full Changelog**: https://github.com/sbarfurth/ukemi/compare/0.0.3...0.0.5 ## 0.0.3 -* Fix remaining references to "Kaisen" in codebase. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/11 +- Fix remaining references to "Kaisen" in codebase. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/11 **Full Changelog**: https://github.com/sbarfurth/ukemi/compare/0.0.2...0.0.3 ## 0.0.2 -* *no user-visible changes* +- _no user-visible changes_ **Full Changelog**: https://github.com/sbarfurth/ukemi/compare/0.0.1...0.0.2 ## 0.0.1 -* Show open button on parent commit. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/1 +- Show open button on parent commit. by @sbarfurth in https://github.com/sbarfurth/ukemi/pull/1 **Full Changelog**: https://github.com/sbarfurth/ukemi/commits/0.0.1 diff --git a/README.md b/README.md index f0202623..8220ef25 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ Feel free to contribute to the extension. ### Requirements -* [Node.js](https://nodejs.org/en) 22 -* [zig](https://ziglang.org/) 15.2 +- [Node.js](https://nodejs.org/en) 22 +- [zig](https://ziglang.org/) 15.2 ### Setup diff --git a/languages/jj-commit.language-configuration.json b/languages/jj-commit.language-configuration.json index 4dfc1f0a..f2b1b2e7 100644 --- a/languages/jj-commit.language-configuration.json +++ b/languages/jj-commit.language-configuration.json @@ -1,19 +1,19 @@ -{ - "comments": { - "lineComment": "JJ:", - "blockComment": [ "JJ:", " " ] - }, - "brackets": [ - ["{", "}"], - ["[", "]"], - ["(", ")"] - ], - "autoClosingPairs": [ - { "open": "{", "close": "}" }, - { "open": "[", "close": "]" }, - { "open": "(", "close": ")" }, - { "open": "'", "close": "'", "notIn": ["string", "comment"] }, - { "open": "\"", "close": "\"", "notIn": ["string"] }, - { "open": "`", "close": "`", "notIn": ["string", "comment"] }, - ] -} \ No newline at end of file +{ + "comments": { + "lineComment": "JJ:", + "blockComment": ["JJ:", " "] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "'", "close": "'", "notIn": ["string", "comment"] }, + { "open": "\"", "close": "\"", "notIn": ["string"] }, + { "open": "`", "close": "`", "notIn": ["string", "comment"] } + ] +} diff --git a/package.json b/package.json index b6b9930d..f59f5e6a 100644 --- a/package.json +++ b/package.json @@ -723,7 +723,8 @@ "pretest": "npm run compile-tests && npm run compile", "check-types": "tsc --noEmit", "lint": "eslint src", - "test": "node out/test/runTest.js" + "test": "node out/test/runTest.js", + "format": "prettier --write ." }, "devDependencies": { "@types/cross-spawn": "^6.0.6", diff --git a/src/decorationProvider.ts b/src/decorationProvider.ts index 1a0e31bc..4646f154 100644 --- a/src/decorationProvider.ts +++ b/src/decorationProvider.ts @@ -6,7 +6,7 @@ import { Event, ThemeColor, } from "vscode"; -import { FileStatus, FileStatusType } from "./repository"; +import { FileStatus, FileStatusType } from "./jj/types"; import { getParams, toJJUri } from "./uri"; const colorOfType = (type: FileStatusType) => { diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000..6cb6cb12 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,95 @@ +import * as os from "os"; +import * as crypto from "crypto"; +import * as vscode from "vscode"; +import path from "path"; +import fs from "fs/promises"; + +export let extensionDir = ""; +export let fakeEditorPath = ""; +export function initExtensionDir(extensionUri: vscode.Uri) { + extensionDir = vscode.Uri.joinPath( + extensionUri, + extensionUri.fsPath.includes("extensions") ? "dist" : "src", + ).fsPath; + + const fakeEditorExecutables: { + [platform in typeof process.platform]?: { + [arch in typeof process.arch]?: string; + }; + } = { + freebsd: { + arm: "fakeeditor_linux_arm", + arm64: "fakeeditor_linux_aarch64", + x64: "fakeeditor_linux_x86_64", + }, + netbsd: { + arm: "fakeeditor_linux_arm", + arm64: "fakeeditor_linux_aarch64", + x64: "fakeeditor_linux_x86_64", + }, + openbsd: { + arm: "fakeeditor_linux_arm", + arm64: "fakeeditor_linux_aarch64", + x64: "fakeeditor_linux_x86_64", + }, + linux: { + arm: "fakeeditor_linux_arm", + arm64: "fakeeditor_linux_aarch64", + x64: "fakeeditor_linux_x86_64", + }, + win32: { + arm64: "fakeeditor_windows_aarch64.exe", + x64: "fakeeditor_windows_x86_64.exe", + }, + darwin: { + arm64: "fakeeditor_macos_aarch64", + x64: "fakeeditor_macos_x86_64", + }, + }; + + const fakeEditorExecutableName = + fakeEditorExecutables[process.platform]?.[process.arch]; + if (fakeEditorExecutableName) { + fakeEditorPath = path.join( + extensionDir, + "fakeeditor", + "zig-out", + "bin", + fakeEditorExecutableName, + ); + } +} + +export async function prepareFakeeditor(): Promise<{ + succeedFakeeditor: () => Promise; + cleanup: () => Promise; + envVars: { [key: string]: string }; +}> { + const random = crypto.randomBytes(16).toString("hex"); + const signalDir = path.join(os.tmpdir(), `ukemi-signal-${random}`); + + await fs.mkdir(signalDir, { recursive: true }); + + return { + envVars: { JJ_FAKEEDITOR_SIGNAL_DIR: signalDir }, + succeedFakeeditor: async () => { + const signalFilePath = path.join(signalDir, "0"); + try { + await fs.writeFile(signalFilePath, ""); + } catch (error) { + throw new Error( + `Failed to write signal file '${signalFilePath}': ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + cleanup: async () => { + try { + await fs.rm(signalDir, { recursive: true, force: true }); + } catch (error) { + throw new Error( + `Failed to cleanup signal directory '${signalDir}': ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + }; +} diff --git a/src/fileSystemProvider.ts b/src/fileSystemProvider.ts index 1b57fa06..e3e7f3e7 100644 --- a/src/fileSystemProvider.ts +++ b/src/fileSystemProvider.ts @@ -13,7 +13,7 @@ import { workspace, } from "vscode"; import { getParams } from "./uri"; -import type { WorkspaceSourceControlManager } from "./repository"; +import type { WorkspaceSourceControlManager } from "./scm/workspace"; import { createThrottledAsyncFn, eventToPromise, diff --git a/src/graphTreeView.ts b/src/graphTreeView.ts index a4c53e90..5442a1c6 100644 --- a/src/graphTreeView.ts +++ b/src/graphTreeView.ts @@ -12,7 +12,8 @@ import { workspace, ThemeIcon, } from "vscode"; -import { ChangeWithDetails, JJRepository } from "./repository"; +import { ChangeWithDetails } from "./jj/types"; +import { JJRepository } from "./jj/repository"; import path from "path"; function getChangeDescription(change: ChangeWithDetails): TreeItemLabel { diff --git a/src/graphWebview.ts b/src/graphWebview.ts index 49349048..dac2047a 100644 --- a/src/graphWebview.ts +++ b/src/graphWebview.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import * as fs from "fs"; -import type { JJRepository } from "./repository"; +import type { JJRepository } from "./jj/repository"; import path from "path"; import { getGraphConfig } from "./config"; diff --git a/src/jj/cli.ts b/src/jj/cli.ts new file mode 100644 index 00000000..63956d17 --- /dev/null +++ b/src/jj/cli.ts @@ -0,0 +1,198 @@ +import spawn from "cross-spawn"; +import * as os from "os"; +import fs from "fs/promises"; +import path from "path"; +import which from "which"; +import * as vscode from "vscode"; +import type { ChildProcess } from "child_process"; +import { SemVer } from "../semver"; +import { getConfig } from "../config"; +import { getLogger } from "../logger"; + +export async function getJJVersion(jjPath: string): Promise { + try { + const version = ( + await handleCommand( + spawn(jjPath, ["version"], { + timeout: 5000, + }), + ) + ) + .toString() + .trim(); + + if (version.startsWith("jj")) { + return SemVer.parse(version); + } + } catch { + // Assume the version + } + return SemVer.default(); +} + +export async function getConfigArgs( + extensionDir: string, + jjVersion: SemVer, +): Promise { + const configPath = path.join(extensionDir, "config.toml"); + + // Determine the config option and value based on jj version + const configOption = jjVersion.isAtLeast(SemVer.parse("0.25.0")) + ? "--config-file" + : "--config-toml"; + + if (configOption === "--config-toml") { + try { + const configValue = await fs.readFile(configPath, "utf8"); + return [configOption, configValue]; + } catch (e) { + getLogger().error( + `Failed to read config file at ${configPath}: ${String(e)}`, + ); + throw e; + } + } else { + return [configOption, configPath]; + } +} + +/** + * If ukemi.commandTimeout is set, returns that value. + * Otherwise, returns the provided default timeout, or 30 seconds if no default is provided. + */ +export function getCommandTimeout( + repositoryRoot: string, + defaultTimeout: number | undefined, +): number { + const { commandTimeout } = getConfig(vscode.Uri.file(repositoryRoot)); + if (commandTimeout !== null && commandTimeout !== undefined) { + return commandTimeout; + } + return defaultTimeout ?? 30000; +} + +/** + * Gets the configured jj executable path from settings. + * If no path is configured, searches through common installation paths before falling back to "jj". + */ +export async function getJJPath( + workspaceFolder: string, +): Promise<{ filepath: string; source: "configured" | "path" | "common" }> { + const { jjPath } = getConfig( + workspaceFolder !== undefined + ? vscode.Uri.file(workspaceFolder) + : undefined, + ); + + if (jjPath) { + if (await which(jjPath, { nothrow: true })) { + return { filepath: jjPath, source: "configured" }; + } else { + throw new Error( + `Configured ukemi.jjPath is not an executable file: ${jjPath}`, + ); + } + } + + const jjInPath = await which("jj", { nothrow: true }); + if (jjInPath) { + return { filepath: jjInPath, source: "path" }; + } + + // It's particularly important to check common locations on MacOS because of https://github.com/microsoft/vscode/issues/30847#issuecomment-420399383 + const commonPaths = [ + path.join(os.homedir(), ".cargo", "bin", "jj"), + path.join(os.homedir(), ".cargo", "bin", "jj.exe"), + path.join(os.homedir(), ".nix-profile", "bin", "jj"), + path.join(os.homedir(), ".local", "bin", "jj"), + path.join(os.homedir(), "bin", "jj"), + "/usr/bin/jj", + "/home/linuxbrew/.linuxbrew/bin/jj", + "/usr/local/bin/jj", + "/opt/homebrew/bin/jj", + "/opt/local/bin/jj", + ]; + + for (const commonPath of commonPaths) { + const jjInCommonPath = await which(commonPath, { nothrow: true }); + if (jjInCommonPath) { + return { filepath: jjInCommonPath, source: "common" }; + } + } + + throw new Error(`jj CLI not found in PATH nor in common locations.`); +} + +export function spawnJJ( + jjPath: string, + args: string[], + options: Parameters[2] & { cwd: string }, +) { + const finalOptions = { + ...options, + timeout: getCommandTimeout(options.cwd, options.timeout), + }; + + getLogger().debug(`spawn: ${jjPath} ${args.join(" ")}`, { + spawnOptions: finalOptions, + }); + + return spawn(jjPath, args, finalOptions); +} + +export function handleJJCommand(childProcess: ChildProcess) { + return handleCommand(childProcess).catch(convertJJErrors); +} + +export function handleCommand(childProcess: ChildProcess) { + return new Promise((resolve, reject) => { + const output: Buffer[] = []; + const errOutput: Buffer[] = []; + childProcess.stdout!.on("data", (data: Buffer) => { + output.push(data); + }); + childProcess.stderr!.on("data", (data: Buffer) => { + errOutput.push(data); + }); + childProcess.on("error", (error: Error) => { + reject(new Error(`Spawning command failed: ${error.message}`)); + }); + childProcess.on("exit", (code, signal) => { + if (code) { + reject( + new Error( + `Command failed with exit code ${code}.\nstdout: ${Buffer.concat(output).toString()}\nstderr: ${Buffer.concat(errOutput).toString()}`, + ), + ); + } else if (signal) { + reject( + new Error( + `Command failed with signal ${signal}.\nstdout: ${Buffer.concat(output).toString()}\nstderr: ${Buffer.concat(errOutput).toString()}`, + ), + ); + } else { + resolve(Buffer.concat(output)); + } + }); + }); +} + +export class ImmutableError extends Error { + constructor(message: string) { + super(message); + this.name = "ImmutableError"; + } +} + +/** + * Detects common error messages from jj and converts them to custom error instances to make them easier to selectively + * handle. + */ +export function convertJJErrors(e: unknown): never { + if (e instanceof Error) { + if (e.message.includes("is immutable")) { + throw new ImmutableError(e.message); + } + } + throw e; +} diff --git a/src/jj/parser.ts b/src/jj/parser.ts new file mode 100644 index 00000000..fc151f0b --- /dev/null +++ b/src/jj/parser.ts @@ -0,0 +1,245 @@ +import path from "path"; +import { Change, FileStatus, RepositoryStatus } from "./types"; + +export async function parseJJStatus( + repositoryRoot: string, + output: string, + immutableChangeIds: ReadonlySet, +): Promise { + const lines = output.split("\n"); + const fileStatuses: FileStatus[] = []; + const conflictedFiles = new Set(); + let workingCopy: Change = { + changeId: "", + commitId: "", + description: "", + isEmpty: false, + isConflict: false, + isImmutable: false, + bookmarks: [], + }; + const parentCommits: Change[] = []; + + const changeRegex = /^(A|M|D|R|C) (.+)$/; + const commitRegex = + /^(Working copy|Parent commit)\s*(\(@-?\))?\s*:\s+(\S+)\s+(\S+)(?:\s+(.+?)\s+\|)?(?:\s+(.*))?$/; + + let isParsingConflicts = false; + + for (const line of lines) { + const trimmedLine = line.trim(); + const ansiStrippedTrimmedLine = await stripAnsiCodes(trimmedLine); + + if ( + ansiStrippedTrimmedLine === "" || + ansiStrippedTrimmedLine.startsWith("Working copy changes:") || + ansiStrippedTrimmedLine.startsWith("The working copy is clean") + ) { + continue; + } + + if ( + ansiStrippedTrimmedLine.includes( + "There are unresolved conflicts at these paths:", + ) + ) { + isParsingConflicts = true; + continue; + } + + if (isParsingConflicts) { + const regions = await extractColoredRegions(trimmedLine); + let filePath = ""; + let firstColoredRegionIndex = -1; + for (let i = 0; i < regions.length; i++) { + if (regions[i].colored) { + firstColoredRegionIndex = i; + break; + } + filePath += regions[i].text; + } + filePath = filePath.trim(); + + if (ansiStrippedTrimmedLine.includes("To resolve the conflicts")) { + isParsingConflicts = false; + continue; + } + + // If filePath is non-empty and we found a colored region after it, it's a conflict line + if (filePath && firstColoredRegionIndex !== -1) { + const normalizedFile = path.normalize(filePath).replace(/\\/g, "/"); + conflictedFiles.add(path.join(repositoryRoot, normalizedFile)); + } else { + isParsingConflicts = false; + } + } + + const changeMatch = changeRegex.exec(ansiStrippedTrimmedLine); + if (changeMatch) { + const [_, type, file] = changeMatch; + + if (type === "R" || type === "C") { + const parsedPaths = parseRenamePaths(file); + if (parsedPaths) { + fileStatuses.push({ + type: type, + file: parsedPaths.toPath, + path: path.join(repositoryRoot, parsedPaths.toPath), + renamedFrom: parsedPaths.fromPath, + }); + } else { + throw new Error( + `Unexpected ${type === "R" ? "rename" : "copy"} line: ${line}`, + ); + } + } else { + const normalizedFile = path.normalize(file).replace(/\\/g, "/"); + fileStatuses.push({ + type: type as "A" | "M" | "D", + file: normalizedFile, + path: path.join(repositoryRoot, normalizedFile), + }); + } + continue; + } + + const commitMatch = commitRegex.exec(line); + if (commitMatch) { + isParsingConflicts = false; + const [ + _firstMatch, + type, + _at, + changeId, + commitId, + bookmarks, + descriptionSection, + ] = commitMatch as unknown as [string, ...(string | undefined)[]]; + + if (!type || !changeId || !commitId || !descriptionSection) { + throw new Error(`Unexpected commit line: ${line}`); + } + + const descriptionRegions = await extractColoredRegions( + descriptionSection.trim(), + ); + const cleanedDescription = descriptionRegions + .filter((region) => !region.colored) + .map((region) => region.text) + .join("") + .trim(); + const jjDescriptors = descriptionRegions + .filter((region) => region.colored) + .map((region) => region.text) + .join(""); + const isEmpty = jjDescriptors.includes("(empty)"); + const isConflict = jjDescriptors.includes("(conflict)"); + + const cleanedChangeId = await stripAnsiCodes(changeId); + + const commitDetails: Change = { + changeId: cleanedChangeId, + commitId: await stripAnsiCodes(commitId), + bookmarks: bookmarks + ? (await stripAnsiCodes(bookmarks)).split(/\s+/) + : [], + description: cleanedDescription, + isEmpty, + isConflict, + isImmutable: immutableChangeIds.has(cleanedChangeId), + }; + + if ((await stripAnsiCodes(type)) === "Working copy") { + workingCopy = commitDetails; + } else if ((await stripAnsiCodes(type)) === "Parent commit") { + parentCommits.push(commitDetails); + } + continue; + } + } + + return { + fileStatuses: fileStatuses, + workingCopy, + parentChanges: parentCommits, + conflictedFiles: conflictedFiles, + }; +} + +export async function extractColoredRegions(input: string) { + const { default: ansiRegex } = await import("ansi-regex"); + const regex = ansiRegex(); + let isColored = false; + const result: { text: string; colored: boolean }[] = []; + + let lastIndex = 0; + + for (const match of input.matchAll(regex)) { + const matchStart = match.index; + const matchEnd = match.index + match[0].length; + + if (matchStart > lastIndex) { + result.push({ + text: input.slice(lastIndex, matchStart), + colored: isColored, + }); + } + + const code = match[0]; + // Update color state + if (code === "\x1b[0m" || code === "\x1b[39m") { + isColored = false; + } else if ( + // standard foreground colors (30–37) + /\x1b\[3[0-7]m/.test(code) || // eslint-disable-line no-control-regex + // bright foreground (90–97) + /\x1b\[9[0-7]m/.test(code) || // eslint-disable-line no-control-regex + // 256-color foreground + /\x1b\[38;5;\d+m/.test(code) || // eslint-disable-line no-control-regex + // 256-color background + /\x1b\[48;5;\d+m/.test(code) || // eslint-disable-line no-control-regex + // truecolor fg + /\x1b\[38;2;\d+;\d+;\d+m/.test(code) || // eslint-disable-line no-control-regex + // truecolor bg + /\x1b\[48;2;\d+;\d+;\d+m/.test(code) // eslint-disable-line no-control-regex + ) { + isColored = true; + } + + lastIndex = matchEnd; + } + + // Remaining text after the last match + if (lastIndex < input.length) { + result.push({ text: input.slice(lastIndex), colored: isColored }); + } + + return result; +} + +export async function stripAnsiCodes(input: string) { + const { default: ansiRegex } = await import("ansi-regex"); + const regex = ansiRegex(); + return input.replace(regex, ""); +} + +const renameRegex = /^(.*)\{\s*(.*?)\s*=>\s*(.*?)\s*\}(.*)$/; + +export function parseRenamePaths( + file: string, +): { fromPath: string; toPath: string } | null { + const renameMatch = renameRegex.exec(file); + if (renameMatch) { + const [_, prefix, fromPart, toPart, suffix] = renameMatch; + const rawFromPath = prefix + fromPart + suffix; + const rawToPath = prefix + toPart + suffix; + const fromPath = path.normalize(rawFromPath).replace(/\\/g, "/"); + const toPath = path.normalize(rawToPath).replace(/\\/g, "/"); + return { fromPath, toPath }; + } + return null; +} + +export function filepathToFileset(filepath: string): string { + return `file:"${filepath.replaceAll(/\\/g, "\\\\")}"`; +} diff --git a/src/repository.ts b/src/jj/repository.ts similarity index 52% rename from src/repository.ts rename to src/jj/repository.ts index 0724400b..ce2ed53e 100644 --- a/src/repository.ts +++ b/src/jj/repository.ts @@ -1,824 +1,19 @@ -import path from "path"; -import * as vscode from "vscode"; import spawn from "cross-spawn"; +import { pathEquals } from "../utils"; import fs from "fs/promises"; -import { getParams, toJJUri } from "./uri"; -import type { JJDecorationProvider } from "./decorationProvider"; -import type { ChildProcess } from "child_process"; -import { anyEvent, pathEquals } from "./utils"; -import { JJFileSystemProvider } from "./fileSystemProvider"; -import * as os from "os"; -import * as crypto from "crypto"; -import which from "which"; -import { getConfig } from "./config"; -import { getLogger } from "./logger"; -import { SemVer } from "./semver"; - -async function getJJVersion(jjPath: string): Promise { - try { - const version = ( - await handleCommand( - spawn(jjPath, ["version"], { - timeout: 5000, - }), - ) - ) - .toString() - .trim(); - - if (version.startsWith("jj")) { - return SemVer.parse(version); - } - } catch { - // Assume the version - } - return SemVer.default(); -} - -export let extensionDir = ""; -export let fakeEditorPath = ""; -export function initExtensionDir(extensionUri: vscode.Uri) { - extensionDir = vscode.Uri.joinPath( - extensionUri, - extensionUri.fsPath.includes("extensions") ? "dist" : "src", - ).fsPath; - - const fakeEditorExecutables: { - [platform in typeof process.platform]?: { - [arch in typeof process.arch]?: string; - }; - } = { - freebsd: { - arm: "fakeeditor_linux_arm", - arm64: "fakeeditor_linux_aarch64", - x64: "fakeeditor_linux_x86_64", - }, - netbsd: { - arm: "fakeeditor_linux_arm", - arm64: "fakeeditor_linux_aarch64", - x64: "fakeeditor_linux_x86_64", - }, - openbsd: { - arm: "fakeeditor_linux_arm", - arm64: "fakeeditor_linux_aarch64", - x64: "fakeeditor_linux_x86_64", - }, - linux: { - arm: "fakeeditor_linux_arm", - arm64: "fakeeditor_linux_aarch64", - x64: "fakeeditor_linux_x86_64", - }, - win32: { - arm64: "fakeeditor_windows_aarch64.exe", - x64: "fakeeditor_windows_x86_64.exe", - }, - darwin: { - arm64: "fakeeditor_macos_aarch64", - x64: "fakeeditor_macos_x86_64", - }, - }; - - const fakeEditorExecutableName = - fakeEditorExecutables[process.platform]?.[process.arch]; - if (fakeEditorExecutableName) { - fakeEditorPath = path.join( - extensionDir, - "fakeeditor", - "zig-out", - "bin", - fakeEditorExecutableName, - ); - } -} - -async function getConfigArgs( - extensionDir: string, - jjVersion: SemVer, -): Promise { - const configPath = path.join(extensionDir, "config.toml"); - - // Determine the config option and value based on jj version - const configOption = jjVersion.isAtLeast(SemVer.parse("0.25.0")) - ? "--config-file" - : "--config-toml"; - - if (configOption === "--config-toml") { - try { - const configValue = await fs.readFile(configPath, "utf8"); - return [configOption, configValue]; - } catch (e) { - getLogger().error( - `Failed to read config file at ${configPath}: ${String(e)}`, - ); - throw e; - } - } else { - return [configOption, configPath]; - } -} - -/** - * If ukemi.commandTimeout is set, returns that value. - * Otherwise, returns the provided default timeout, or 30 seconds if no default is provided. - */ -function getCommandTimeout( - repositoryRoot: string, - defaultTimeout: number | undefined, -): number { - const { commandTimeout } = getConfig(vscode.Uri.file(repositoryRoot)); - if (commandTimeout !== null && commandTimeout !== undefined) { - return commandTimeout; - } - return defaultTimeout ?? 30000; -} - -/** - * Gets the configured jj executable path from settings. - * If no path is configured, searches through common installation paths before falling back to "jj". - */ -async function getJJPath( - workspaceFolder: string, -): Promise<{ filepath: string; source: "configured" | "path" | "common" }> { - const { jjPath } = getConfig( - workspaceFolder !== undefined - ? vscode.Uri.file(workspaceFolder) - : undefined, - ); - - if (jjPath) { - if (await which(jjPath, { nothrow: true })) { - return { filepath: jjPath, source: "configured" }; - } else { - throw new Error( - `Configured ukemi.jjPath is not an executable file: ${jjPath}`, - ); - } - } - - const jjInPath = await which("jj", { nothrow: true }); - if (jjInPath) { - return { filepath: jjInPath, source: "path" }; - } - - // It's particularly important to check common locations on MacOS because of https://github.com/microsoft/vscode/issues/30847#issuecomment-420399383 - const commonPaths = [ - path.join(os.homedir(), ".cargo", "bin", "jj"), - path.join(os.homedir(), ".cargo", "bin", "jj.exe"), - path.join(os.homedir(), ".nix-profile", "bin", "jj"), - path.join(os.homedir(), ".local", "bin", "jj"), - path.join(os.homedir(), "bin", "jj"), - "/usr/bin/jj", - "/home/linuxbrew/.linuxbrew/bin/jj", - "/usr/local/bin/jj", - "/opt/homebrew/bin/jj", - "/opt/local/bin/jj", - ]; - - for (const commonPath of commonPaths) { - const jjInCommonPath = await which(commonPath, { nothrow: true }); - if (jjInCommonPath) { - return { filepath: jjInCommonPath, source: "common" }; - } - } - - throw new Error(`jj CLI not found in PATH nor in common locations.`); -} - -function spawnJJ( - jjPath: string, - args: string[], - options: Parameters[2] & { cwd: string }, -) { - const finalOptions = { - ...options, - timeout: getCommandTimeout(options.cwd, options.timeout), - }; - - getLogger().debug(`spawn: ${jjPath} ${args.join(" ")}`, { - spawnOptions: finalOptions, - }); - - return spawn(jjPath, args, finalOptions); -} - -function handleJJCommand(childProcess: ChildProcess) { - return handleCommand(childProcess).catch(convertJJErrors); -} - -function handleCommand(childProcess: ChildProcess) { - return new Promise((resolve, reject) => { - const output: Buffer[] = []; - const errOutput: Buffer[] = []; - childProcess.stdout!.on("data", (data: Buffer) => { - output.push(data); - }); - childProcess.stderr!.on("data", (data: Buffer) => { - errOutput.push(data); - }); - childProcess.on("error", (error: Error) => { - reject(new Error(`Spawning command failed: ${error.message}`)); - }); - childProcess.on("close", (code, signal) => { - if (code) { - reject( - new Error( - `Command failed with exit code ${code}.\nstdout: ${Buffer.concat(output).toString()}\nstderr: ${Buffer.concat(errOutput).toString()}`, - ), - ); - } else if (signal) { - reject( - new Error( - `Command failed with signal ${signal}.\nstdout: ${Buffer.concat(output).toString()}\nstderr: ${Buffer.concat(errOutput).toString()}`, - ), - ); - } else { - resolve(Buffer.concat(output)); - } - }); - }); -} - -export class ImmutableError extends Error { - constructor(message: string) { - super(message); - this.name = "ImmutableError"; - } -} - -/** - * Detects common error messages from jj and converts them to custom error instances to make them easier to selectively - * handle. - */ -function convertJJErrors(e: unknown): never { - if (e instanceof Error) { - if (e.message.includes("is immutable")) { - throw new ImmutableError(e.message); - } - } - throw e; -} - -export class WorkspaceSourceControlManager { - repoInfos: - | Map< - string, - { - jjPath: Awaited>; - jjVersion: SemVer; - jjConfigArgs: string[]; - repoRoot: string; - } - > - | undefined; - repoSCMs: RepositorySourceControlManager[] = []; - subscriptions: { - dispose(): unknown; - }[] = []; - fileSystemProvider: JJFileSystemProvider; - - private _onDidRepoUpdate = new vscode.EventEmitter<{ - repoSCM: RepositorySourceControlManager; - }>(); - readonly onDidRepoUpdate: vscode.Event<{ - repoSCM: RepositorySourceControlManager; - }> = this._onDidRepoUpdate.event; - - constructor(private decorationProvider: JJDecorationProvider) { - this.fileSystemProvider = new JJFileSystemProvider(this); - this.subscriptions.push(this.fileSystemProvider); - this.subscriptions.push( - vscode.workspace.registerFileSystemProvider( - "jj", - this.fileSystemProvider, - { - isReadonly: true, - isCaseSensitive: true, - }, - ), - ); - } - - async refresh() { - const newRepoInfos = new Map< - string, - { - jjPath: Awaited>; - jjVersion: SemVer; - jjConfigArgs: string[]; - repoRoot: string; - } - >(); - for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { - try { - const jjPath = await getJJPath(workspaceFolder.uri.fsPath); - const jjVersion = await getJJVersion(jjPath.filepath); - const jjConfigArgs = await getConfigArgs(extensionDir, jjVersion); - - const repoRoot = ( - await handleCommand( - spawnJJ(jjPath.filepath, ["root"], { - timeout: 5000, - cwd: workspaceFolder.uri.fsPath, - }), - ) - ) - .toString() - .trim(); - - const repoUri = vscode.Uri.file( - repoRoot.replace(/^\\\\\?\\UNC\\/, "\\\\"), - ).toString(); - - if (!newRepoInfos.has(repoUri)) { - newRepoInfos.set(repoUri, { - jjPath, - jjVersion, - jjConfigArgs, - repoRoot, - }); - } - } catch (e) { - if (e instanceof Error && e.message.includes("no jj repo in")) { - getLogger().debug(`No jj repo in ${workspaceFolder.uri.fsPath}`); - } else { - getLogger().error( - `Error while initializing ukemi in workspace ${workspaceFolder.uri.fsPath}: ${String(e)}`, - ); - } - continue; - } - } - - let isAnyRepoChanged = false; - for (const [key, value] of newRepoInfos) { - const oldValue = this.repoInfos?.get(key); - if (!oldValue) { - isAnyRepoChanged = true; - getLogger().info(`Detected new jj repo in workspace: ${key}`); - } else if ( - !oldValue.jjVersion.equals(value.jjVersion) || - oldValue.jjPath.filepath !== value.jjPath.filepath || - oldValue.jjConfigArgs.join(" ") !== value.jjConfigArgs.join(" ") || - oldValue.repoRoot !== value.repoRoot - ) { - isAnyRepoChanged = true; - getLogger().info( - `Detected change that requires reinitialization in workspace: ${key}`, - ); - } - } - for (const key of this.repoInfos?.keys() || []) { - if (!newRepoInfos.has(key)) { - isAnyRepoChanged = true; - getLogger().info(`Detected jj repo removal in workspace: ${key}`); - } - } - this.repoInfos = newRepoInfos; - - if (isAnyRepoChanged) { - const repoSCMs: RepositorySourceControlManager[] = []; - for (const [ - workspaceFolder, - { repoRoot, jjPath, jjVersion, jjConfigArgs }, - ] of newRepoInfos.entries()) { - getLogger().info( - `Initializing ukemi in workspace ${workspaceFolder}. Using ${jjVersion.toString()} at ${jjPath.filepath} (${jjPath.source}).`, - ); - const repoSCM = new RepositorySourceControlManager( - repoRoot, - this.decorationProvider, - this.fileSystemProvider, - jjPath.filepath, - jjVersion, - jjConfigArgs, - ); - repoSCM.onDidUpdate( - () => { - this._onDidRepoUpdate.fire({ repoSCM }); - }, - undefined, - repoSCM.subscriptions, - ); - repoSCMs.push(repoSCM); - } - - for (const repoSCM of this.repoSCMs) { - repoSCM.dispose(); - } - this.repoSCMs = repoSCMs; - } - return isAnyRepoChanged; - } - - getRepositoryFromUri(uri: vscode.Uri) { - return this.repoSCMs.find((repo) => { - return !path.relative(repo.repositoryRoot, uri.fsPath).startsWith(".."); - })?.repository; - } - - getRepositoryFromResourceGroup( - resourceGroup: vscode.SourceControlResourceGroup, - ) { - return this.repoSCMs.find((repo) => { - return ( - resourceGroup === repo.workingCopyResourceGroup || - repo.parentResourceGroups.includes(resourceGroup) - ); - })?.repository; - } - - getRepositoryFromSourceControl(sourceControl: vscode.SourceControl) { - return this.repoSCMs.find((repo) => repo.sourceControl === sourceControl) - ?.repository; - } - - getRepositorySourceControlManagerFromUri(uri: vscode.Uri) { - return this.repoSCMs.find((repo) => { - return !path.relative(repo.repositoryRoot, uri.fsPath).startsWith(".."); - }); - } - - getRepositorySourceControlManagerFromResourceGroup( - resourceGroup: vscode.SourceControlResourceGroup, - ) { - return this.repoSCMs.find( - (repo) => - repo.workingCopyResourceGroup === resourceGroup || - repo.parentResourceGroups.includes(resourceGroup), - ); - } - - getResourceGroupFromResourceState( - resourceState: vscode.SourceControlResourceState, - ) { - const resourceUri = resourceState.resourceUri; - - for (const repo of this.repoSCMs) { - const groups = [ - repo.workingCopyResourceGroup, - ...repo.parentResourceGroups, - ]; - - for (const group of groups) { - if ( - group.resourceStates.some( - (state) => state.resourceUri.toString() === resourceUri.toString(), - ) - ) { - return group; - } - } - } - - throw new Error("Resource state not found in any resource group"); - } - - dispose() { - for (const subscription of this.repoSCMs) { - subscription.dispose(); - } - for (const subscription of this.subscriptions) { - subscription.dispose(); - } - } -} - -export function provideOriginalResource(uri: vscode.Uri) { - if (!["file", "jj"].includes(uri.scheme)) { - return undefined; - } - - let rev = "@"; - if (uri.scheme === "jj") { - const params = getParams(uri); - if ("diffOriginalRev" in params) { - // It doesn't make sense to show a quick diff for the left side of a diff. Diffception? - return undefined; - } - rev = params.rev; - } - const filePath = uri.fsPath; - const originalUri = toJJUri(vscode.Uri.file(filePath), { - diffOriginalRev: rev, - }); - - return originalUri; -} - -class RepositorySourceControlManager { - subscriptions: { - dispose(): unknown; - }[] = []; - sourceControl: vscode.SourceControl; - workingCopyResourceGroup: vscode.SourceControlResourceGroup; - parentResourceGroups: vscode.SourceControlResourceGroup[] = []; - repository: JJRepository; - checkForUpdatesPromise: Promise | undefined; - - private _onDidUpdate = new vscode.EventEmitter(); - readonly onDidUpdate: vscode.Event = this._onDidUpdate.event; - - operationId: string | undefined; // the latest operation id seen by this manager - fileStatusesByChange: Map = new Map(); - conflictedFilesByChange: Map> = new Map(); - trackedFiles: Set | null = null; - status: RepositoryStatus | undefined; - parentShowResults: Map = new Map(); - - constructor( - public repositoryRoot: string, - private decorationProvider: JJDecorationProvider, - private fileSystemProvider: JJFileSystemProvider, - jjPath: string, - jjVersion: SemVer, - jjConfigArgs: string[], - ) { - this.repository = new JJRepository( - repositoryRoot, - jjPath, - jjVersion, - jjConfigArgs, - ); - - this.sourceControl = vscode.scm.createSourceControl( - "jj", - path.basename(repositoryRoot), - vscode.Uri.file(repositoryRoot), - ); - this.subscriptions.push(this.sourceControl); - - this.workingCopyResourceGroup = this.sourceControl.createResourceGroup( - "@", - "Working Copy", - ); - this.subscriptions.push(this.workingCopyResourceGroup); - - // Set up the SourceControlInputBox - this.sourceControl.inputBox.placeholder = "Message (press {0} to commit)"; - - // Link the acceptInputCommand to the SourceControl instance - this.sourceControl.acceptInputCommand = { - command: "jj.commit", - title: "Commit changes", - arguments: [this.sourceControl], - }; - - this.sourceControl.quickDiffProvider = { - provideOriginalResource, - }; - - const watcherOperations = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern( - path.join(this.repositoryRoot, ".jj/repo/op_store/operations"), - "*", - ), - ); - this.subscriptions.push(watcherOperations); - const repoChangedWatchEvent = anyEvent( - watcherOperations.onDidCreate, - watcherOperations.onDidChange, - watcherOperations.onDidDelete, - ); - repoChangedWatchEvent( - async (_uri) => { - this.fileSystemProvider.onDidChangeRepository({ - repositoryRoot: this.repositoryRoot, - }); - await this.checkForUpdates(); - }, - undefined, - this.subscriptions, - ); - } - - async checkForUpdates() { - if (!this.checkForUpdatesPromise) { - this.checkForUpdatesPromise = this.checkForUpdatesUnsafe(); - try { - await this.checkForUpdatesPromise; - } finally { - this.checkForUpdatesPromise = undefined; - } - } else { - await this.checkForUpdatesPromise; - } - } - - /** - * This should never be called concurrently. - */ - async checkForUpdatesUnsafe() { - const latestOperationId = await this.repository.getLatestOperationId({ - noIntegrate: true, - }); - if (this.operationId !== latestOperationId) { - this.operationId = latestOperationId; - const status = await this.repository.status({ noIntegrate: true }); - - await this.updateState(status); - this.render(); - - this._onDidUpdate.fire(undefined); - } - } - - async updateState(status: RepositoryStatus) { - const newParentShowResults = new Map(); - const newFileStatusesByChange = new Map([ - ["@", status.fileStatuses], - ]); - const newConflictedFilesByChange = new Map>([ - ["@", status.conflictedFiles], - ]); - - // Only check for tracked files in store backends we know. Unknown backends - // may not be suitable for listing all their contents. Otherwise tracked - // files will are set to `null` to signal they are unsupported. - let newTrackedFiles: Set | null = null; - const isKnownStoreBackend = await this.repository.isKnownStoreBackend(); - if (isKnownStoreBackend) { - newTrackedFiles = new Set(); - const trackedFilesList = await this.repository.fileList({ - noIntegrate: true, - }); - for (const t of trackedFilesList) { - const pathParts = t.split(path.sep); - let currentPath = this.repositoryRoot + path.sep; - for (const p of pathParts) { - currentPath += p; - newTrackedFiles.add(currentPath); - currentPath += path.sep; - } - } - } - - const parentShowPromises = status.parentChanges.map( - async (parentChange) => { - const showResult = await this.repository.show(parentChange.changeId, { - noIntegrate: true, - }); - return { changeId: parentChange.changeId, showResult }; - }, - ); - - const parentShowResultsArray = await Promise.all(parentShowPromises); - - for (const { changeId, showResult } of parentShowResultsArray) { - newParentShowResults.set(changeId, showResult); - newFileStatusesByChange.set(changeId, showResult.fileStatuses); - newConflictedFilesByChange.set(changeId, showResult.conflictedFiles); - } - - this.status = status; - this.fileStatusesByChange = newFileStatusesByChange; - this.conflictedFilesByChange = newConflictedFilesByChange; - this.parentShowResults = newParentShowResults; - this.trackedFiles = newTrackedFiles; - } - - static getLabel(prefix: string, change: Change) { - return `${prefix} ${ - change.description ? ` • ${change.description}` : "" - }${change.isEmpty ? " (empty)" : ""}${ - change.isConflict ? " (conflict)" : "" - }${change.description ? "" : " (no description)"}`; - } - - render() { - if (!this.status?.workingCopy) { - throw new Error( - "Cannot render source control without a current working copy change.", - ); - } - - this.workingCopyResourceGroup.label = "Working Copy"; - this.workingCopyResourceGroup.resourceStates = this.status.fileStatuses.map( - (fileStatus) => { - return { - resourceUri: vscode.Uri.file(fileStatus.path), - decorations: { - strikeThrough: fileStatus.type === "D", - tooltip: path.basename(fileStatus.file), - }, - command: getResourceStateCommand( - fileStatus, - toJJUri(vscode.Uri.file(`${fileStatus.path}`), { - diffOriginalRev: "@", - }), - vscode.Uri.file(fileStatus.path), - ), - }; - }, - ); - this.sourceControl.count = this.status.fileStatuses.length; - - const updatedGroups: vscode.SourceControlResourceGroup[] = []; - for (const group of this.parentResourceGroups) { - const parentChange = this.status.parentChanges.find( - (change) => change.changeId === group.id, - ); - if (!parentChange) { - group.dispose(); - } else { - group.label = RepositorySourceControlManager.getLabel( - "Parent Commit", - parentChange, - ); - updatedGroups.push(group); - } - } - this.parentResourceGroups = updatedGroups; - - for (const parentChange of this.status.parentChanges.filter( - (c) => !c.isImmutable, - )) { - let parentChangeResourceGroup!: vscode.SourceControlResourceGroup; - - const parentGroup = this.parentResourceGroups.find( - (group) => group.id === parentChange.changeId, - ); - if (!parentGroup) { - parentChangeResourceGroup = this.sourceControl.createResourceGroup( - parentChange.changeId, - RepositorySourceControlManager.getLabel( - "Parent Commit", - parentChange, - ), - ); - this.parentResourceGroups.push(parentChangeResourceGroup); - } else { - parentChangeResourceGroup = parentGroup; - } - - const showResult = this.parentShowResults.get(parentChange.changeId); - if (showResult) { - parentChangeResourceGroup.resourceStates = showResult.fileStatuses.map( - (parentStatus) => { - return { - resourceUri: toJJUri(vscode.Uri.file(parentStatus.path), { - rev: parentChange.changeId, - }), - decorations: { - strikeThrough: parentStatus.type === "D", - tooltip: path.basename(parentStatus.file), - }, - command: getResourceStateCommand( - parentStatus, - toJJUri(vscode.Uri.file(parentStatus.path), { - diffOriginalRev: parentChange.changeId, - }), - vscode.Uri.file(parentStatus.path), - ), - }; - }, - ); - } - } - - this.decorationProvider.onRefresh( - this.fileStatusesByChange, - this.trackedFiles, - this.conflictedFilesByChange, - ); - } - - dispose() { - for (const subscription of this.subscriptions) { - subscription.dispose(); - } - for (const group of this.parentResourceGroups) { - group.dispose(); - } - } -} - -function getResourceStateCommand( - fileStatus: FileStatus, - beforeUri: vscode.Uri, - afterUri: vscode.Uri, -): vscode.Command { - if (fileStatus.type === "D") { - return { - title: "Open", - command: "vscode.open", - arguments: [ - beforeUri, - {} satisfies vscode.TextDocumentShowOptions, - `${fileStatus.file} (Deleted)`, - ], - }; - } - return { - title: "Open", - command: "vscode.open", - arguments: [afterUri], - }; -} - -interface ShowTemplateField { - template: string; - setter?: (value: string, show: Show) => void; -} +import path from "path"; +import * as vscode from "vscode"; +import { SemVer } from "../semver"; +import { + spawnJJ, + handleJJCommand, + ImmutableError, + convertJJErrors, +} from "./cli"; +import { parseJJStatus, parseRenamePaths, filepathToFileset } from "./parser"; +import { RepositoryStatus, Show, Operation, ShowTemplateField } from "./types"; +import { getLogger } from "../logger"; +import { fakeEditorPath, prepareFakeeditor } from "../env"; export class JJRepository { statusCache: RepositoryStatus | undefined; @@ -2093,332 +1288,3 @@ export class JJRepository { ); } } - -export type FileStatusType = "A" | "M" | "D" | "R" | "C"; - -export type FileStatus = { - type: FileStatusType; - file: string; - path: string; - renamedFrom?: string; -}; - -export interface Change { - changeId: string; - commitId: string; - bookmarks: string[]; - description: string; - isEmpty: boolean; - isConflict: boolean; - isImmutable: boolean; -} - -export interface ChangeWithDetails extends Change { - author: { - name: string; - email: string; - }; - authoredDate: string; - parentChangeIds: string[]; - isCurrentWorkingCopy: boolean; - isSynced: boolean; -} - -export type RepositoryStatus = { - fileStatuses: FileStatus[]; - workingCopy: Change; - parentChanges: Change[]; - conflictedFiles: Set; -}; - -export type Show = { - change: ChangeWithDetails; - fileStatuses: FileStatus[]; - conflictedFiles: Set; -}; - -export type Operation = { - id: string; - description: string; - tags: string; - start: string; - user: string; - snapshot: boolean; -}; - -async function parseJJStatus( - repositoryRoot: string, - output: string, - immutableChangeIds: ReadonlySet, -): Promise { - const lines = output.split("\n"); - const fileStatuses: FileStatus[] = []; - const conflictedFiles = new Set(); - let workingCopy: Change = { - changeId: "", - commitId: "", - description: "", - isEmpty: false, - isConflict: false, - isImmutable: false, - bookmarks: [], - }; - const parentCommits: Change[] = []; - - const changeRegex = /^(A|M|D|R|C) (.+)$/; - const commitRegex = - /^(Working copy|Parent commit)\s*(\(@-?\))?\s*:\s+(\S+)\s+(\S+)(?:\s+(.+?)\s+\|)?(?:\s+(.*))?$/; - - let isParsingConflicts = false; - - for (const line of lines) { - const trimmedLine = line.trim(); - const ansiStrippedTrimmedLine = await stripAnsiCodes(trimmedLine); - - if ( - ansiStrippedTrimmedLine === "" || - ansiStrippedTrimmedLine.startsWith("Working copy changes:") || - ansiStrippedTrimmedLine.startsWith("The working copy is clean") - ) { - continue; - } - - if ( - ansiStrippedTrimmedLine.includes( - "There are unresolved conflicts at these paths:", - ) - ) { - isParsingConflicts = true; - continue; - } - - if (isParsingConflicts) { - const regions = await extractColoredRegions(trimmedLine); - let filePath = ""; - let firstColoredRegionIndex = -1; - for (let i = 0; i < regions.length; i++) { - if (regions[i].colored) { - firstColoredRegionIndex = i; - break; - } - filePath += regions[i].text; - } - filePath = filePath.trim(); - - if (ansiStrippedTrimmedLine.includes("To resolve the conflicts")) { - isParsingConflicts = false; - continue; - } - - // If filePath is non-empty and we found a colored region after it, it's a conflict line - if (filePath && firstColoredRegionIndex !== -1) { - const normalizedFile = path.normalize(filePath).replace(/\\/g, "/"); - conflictedFiles.add(path.join(repositoryRoot, normalizedFile)); - } else { - isParsingConflicts = false; - } - } - - const changeMatch = changeRegex.exec(ansiStrippedTrimmedLine); - if (changeMatch) { - const [_, type, file] = changeMatch; - - if (type === "R" || type === "C") { - const parsedPaths = parseRenamePaths(file); - if (parsedPaths) { - fileStatuses.push({ - type: type, - file: parsedPaths.toPath, - path: path.join(repositoryRoot, parsedPaths.toPath), - renamedFrom: parsedPaths.fromPath, - }); - } else { - throw new Error( - `Unexpected ${type === "R" ? "rename" : "copy"} line: ${line}`, - ); - } - } else { - const normalizedFile = path.normalize(file).replace(/\\/g, "/"); - fileStatuses.push({ - type: type as "A" | "M" | "D", - file: normalizedFile, - path: path.join(repositoryRoot, normalizedFile), - }); - } - continue; - } - - const commitMatch = commitRegex.exec(line); - if (commitMatch) { - isParsingConflicts = false; - const [ - _firstMatch, - type, - _at, - changeId, - commitId, - bookmarks, - descriptionSection, - ] = commitMatch as unknown as [string, ...(string | undefined)[]]; - - if (!type || !changeId || !commitId || !descriptionSection) { - throw new Error(`Unexpected commit line: ${line}`); - } - - const descriptionRegions = await extractColoredRegions( - descriptionSection.trim(), - ); - const cleanedDescription = descriptionRegions - .filter((region) => !region.colored) - .map((region) => region.text) - .join("") - .trim(); - const jjDescriptors = descriptionRegions - .filter((region) => region.colored) - .map((region) => region.text) - .join(""); - const isEmpty = jjDescriptors.includes("(empty)"); - const isConflict = jjDescriptors.includes("(conflict)"); - - const cleanedChangeId = await stripAnsiCodes(changeId); - - const commitDetails: Change = { - changeId: cleanedChangeId, - commitId: await stripAnsiCodes(commitId), - bookmarks: bookmarks - ? (await stripAnsiCodes(bookmarks)).split(/\s+/) - : [], - description: cleanedDescription, - isEmpty, - isConflict, - isImmutable: immutableChangeIds.has(cleanedChangeId), - }; - - if ((await stripAnsiCodes(type)) === "Working copy") { - workingCopy = commitDetails; - } else if ((await stripAnsiCodes(type)) === "Parent commit") { - parentCommits.push(commitDetails); - } - continue; - } - } - - return { - fileStatuses: fileStatuses, - workingCopy, - parentChanges: parentCommits, - conflictedFiles: conflictedFiles, - }; -} - -async function extractColoredRegions(input: string) { - const { default: ansiRegex } = await import("ansi-regex"); - const regex = ansiRegex(); - let isColored = false; - const result: { text: string; colored: boolean }[] = []; - - let lastIndex = 0; - - for (const match of input.matchAll(regex)) { - const matchStart = match.index; - const matchEnd = match.index + match[0].length; - - if (matchStart > lastIndex) { - result.push({ - text: input.slice(lastIndex, matchStart), - colored: isColored, - }); - } - - const code = match[0]; - // Update color state - if (code === "\x1b[0m" || code === "\x1b[39m") { - isColored = false; - } else if ( - // standard foreground colors (30–37) - /\x1b\[3[0-7]m/.test(code) || // eslint-disable-line no-control-regex - // bright foreground (90–97) - /\x1b\[9[0-7]m/.test(code) || // eslint-disable-line no-control-regex - // 256-color foreground - /\x1b\[38;5;\d+m/.test(code) || // eslint-disable-line no-control-regex - // 256-color background - /\x1b\[48;5;\d+m/.test(code) || // eslint-disable-line no-control-regex - // truecolor fg - /\x1b\[38;2;\d+;\d+;\d+m/.test(code) || // eslint-disable-line no-control-regex - // truecolor bg - /\x1b\[48;2;\d+;\d+;\d+m/.test(code) // eslint-disable-line no-control-regex - ) { - isColored = true; - } - - lastIndex = matchEnd; - } - - // Remaining text after the last match - if (lastIndex < input.length) { - result.push({ text: input.slice(lastIndex), colored: isColored }); - } - - return result; -} - -async function stripAnsiCodes(input: string) { - const { default: ansiRegex } = await import("ansi-regex"); - const regex = ansiRegex(); - return input.replace(regex, ""); -} - -const renameRegex = /^(.*)\{\s*(.*?)\s*=>\s*(.*?)\s*\}(.*)$/; - -export function parseRenamePaths( - file: string, -): { fromPath: string; toPath: string } | null { - const renameMatch = renameRegex.exec(file); - if (renameMatch) { - const [_, prefix, fromPart, toPart, suffix] = renameMatch; - const rawFromPath = prefix + fromPart + suffix; - const rawToPath = prefix + toPart + suffix; - const fromPath = path.normalize(rawFromPath).replace(/\\/g, "/"); - const toPath = path.normalize(rawToPath).replace(/\\/g, "/"); - return { fromPath, toPath }; - } - return null; -} - -function filepathToFileset(filepath: string): string { - return `file:"${filepath.replaceAll(/\\/g, "\\\\")}"`; -} - -async function prepareFakeeditor(): Promise<{ - succeedFakeeditor: () => Promise; - cleanup: () => Promise; - envVars: { [key: string]: string }; -}> { - const random = crypto.randomBytes(16).toString("hex"); - const signalDir = path.join(os.tmpdir(), `ukemi-signal-${random}`); - - await fs.mkdir(signalDir, { recursive: true }); - - return { - envVars: { JJ_FAKEEDITOR_SIGNAL_DIR: signalDir }, - succeedFakeeditor: async () => { - const signalFilePath = path.join(signalDir, "0"); - try { - await fs.writeFile(signalFilePath, ""); - } catch (error) { - throw new Error( - `Failed to write signal file '${signalFilePath}': ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - cleanup: async () => { - try { - await fs.rm(signalDir, { recursive: true, force: true }); - } catch (error) { - throw new Error( - `Failed to cleanup signal directory '${signalDir}': ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - }; -} diff --git a/src/jj/types.ts b/src/jj/types.ts new file mode 100644 index 00000000..f828e4fa --- /dev/null +++ b/src/jj/types.ts @@ -0,0 +1,56 @@ +export interface ShowTemplateField { + template: string; + setter?: (value: string, show: Show) => void; +} + +export type FileStatusType = "A" | "M" | "D" | "R" | "C"; + +export type FileStatus = { + type: FileStatusType; + file: string; + path: string; + renamedFrom?: string; +}; + +export interface Change { + changeId: string; + commitId: string; + bookmarks: string[]; + description: string; + isEmpty: boolean; + isConflict: boolean; + isImmutable: boolean; +} + +export interface ChangeWithDetails extends Change { + author: { + name: string; + email: string; + }; + authoredDate: string; + parentChangeIds: string[]; + isCurrentWorkingCopy: boolean; + isSynced: boolean; +} + +export type RepositoryStatus = { + fileStatuses: FileStatus[]; + workingCopy: Change; + parentChanges: Change[]; + conflictedFiles: Set; +}; + +export type Show = { + change: ChangeWithDetails; + fileStatuses: FileStatus[]; + conflictedFiles: Set; +}; + +export type Operation = { + id: string; + description: string; + tags: string; + start: string; + user: string; + snapshot: boolean; +}; diff --git a/src/main.ts b/src/main.ts index 5098da42..8e77db15 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,10 @@ import * as vscode from "vscode"; import path from "path"; -import "./repository"; -import { initExtensionDir, WorkspaceSourceControlManager } from "./repository"; -import type { JJRepository, ChangeWithDetails, FileStatus } from "./repository"; + +import { initExtensionDir } from "./env"; +import { WorkspaceSourceControlManager } from "./scm/workspace"; +import type { JJRepository } from "./jj/repository"; +import type { ChangeWithDetails, FileStatus } from "./jj/types"; import { JJDecorationProvider } from "./decorationProvider"; import { OperationLogManager, diff --git a/src/open_file.ts b/src/open_file.ts index 3e4aaf75..adfa2e13 100644 --- a/src/open_file.ts +++ b/src/open_file.ts @@ -1,8 +1,6 @@ import * as vscode from "vscode"; -import { - provideOriginalResource, - WorkspaceSourceControlManager, -} from "./repository"; +import { provideOriginalResource } from "./scm/utils"; +import { WorkspaceSourceControlManager } from "./scm/workspace"; import { getParams } from "./uri"; import { pathEquals } from "./utils"; import path from "path"; diff --git a/src/operationLogTreeView.ts b/src/operationLogTreeView.ts index 7ed287ee..dcf971f0 100644 --- a/src/operationLogTreeView.ts +++ b/src/operationLogTreeView.ts @@ -7,7 +7,8 @@ import { window, MarkdownString, } from "vscode"; -import { JJRepository, Operation } from "./repository"; +import { JJRepository } from "./jj/repository"; +import { Operation } from "./jj/types"; import path from "path"; export class OperationLogManager { diff --git a/src/scm/repository.ts b/src/scm/repository.ts new file mode 100644 index 00000000..37bfdcf9 --- /dev/null +++ b/src/scm/repository.ts @@ -0,0 +1,297 @@ +import * as vscode from "vscode"; +import path from "path"; +import { anyEvent } from "../utils"; +import { JJDecorationProvider } from "../decorationProvider"; +import { JJFileSystemProvider } from "../fileSystemProvider"; +import { SemVer } from "../semver"; +import { JJRepository } from "../jj/repository"; +import { RepositoryStatus, FileStatus, Change, Show } from "../jj/types"; +import { toJJUri } from "../uri"; +import { provideOriginalResource, getResourceStateCommand } from "./utils"; + +export class RepositorySourceControlManager { + subscriptions: { + dispose(): unknown; + }[] = []; + sourceControl: vscode.SourceControl; + workingCopyResourceGroup: vscode.SourceControlResourceGroup; + parentResourceGroups: vscode.SourceControlResourceGroup[] = []; + repository: JJRepository; + checkForUpdatesPromise: Promise | undefined; + + private _onDidUpdate = new vscode.EventEmitter(); + readonly onDidUpdate: vscode.Event = this._onDidUpdate.event; + + operationId: string | undefined; // the latest operation id seen by this manager + fileStatusesByChange: Map = new Map(); + conflictedFilesByChange: Map> = new Map(); + trackedFiles: Set | null = null; + status: RepositoryStatus | undefined; + parentShowResults: Map = new Map(); + + constructor( + public repositoryRoot: string, + private decorationProvider: JJDecorationProvider, + private fileSystemProvider: JJFileSystemProvider, + jjPath: string, + jjVersion: SemVer, + jjConfigArgs: string[], + ) { + this.repository = new JJRepository( + repositoryRoot, + jjPath, + jjVersion, + jjConfigArgs, + ); + + this.sourceControl = vscode.scm.createSourceControl( + "jj", + path.basename(repositoryRoot), + vscode.Uri.file(repositoryRoot), + ); + this.subscriptions.push(this.sourceControl); + + this.workingCopyResourceGroup = this.sourceControl.createResourceGroup( + "@", + "Working Copy", + ); + this.subscriptions.push(this.workingCopyResourceGroup); + + // Set up the SourceControlInputBox + this.sourceControl.inputBox.placeholder = "Message (press {0} to commit)"; + + // Link the acceptInputCommand to the SourceControl instance + this.sourceControl.acceptInputCommand = { + command: "jj.commit", + title: "Commit changes", + arguments: [this.sourceControl], + }; + + this.sourceControl.quickDiffProvider = { + provideOriginalResource, + }; + + const watcherOperations = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern( + path.join(this.repositoryRoot, ".jj/repo/op_store/operations"), + "*", + ), + ); + this.subscriptions.push(watcherOperations); + const repoChangedWatchEvent = anyEvent( + watcherOperations.onDidCreate, + watcherOperations.onDidChange, + watcherOperations.onDidDelete, + ); + repoChangedWatchEvent( + async (_uri) => { + this.fileSystemProvider.onDidChangeRepository({ + repositoryRoot: this.repositoryRoot, + }); + await this.checkForUpdates(); + }, + undefined, + this.subscriptions, + ); + } + + async checkForUpdates() { + if (!this.checkForUpdatesPromise) { + this.checkForUpdatesPromise = this.checkForUpdatesUnsafe(); + try { + await this.checkForUpdatesPromise; + } finally { + this.checkForUpdatesPromise = undefined; + } + } else { + await this.checkForUpdatesPromise; + } + } + + /** + * This should never be called concurrently. + */ + async checkForUpdatesUnsafe() { + const latestOperationId = await this.repository.getLatestOperationId({ + noIntegrate: true, + }); + if (this.operationId !== latestOperationId) { + this.operationId = latestOperationId; + const status = await this.repository.status({ noIntegrate: true }); + + await this.updateState(status); + this.render(); + + this._onDidUpdate.fire(undefined); + } + } + + async updateState(status: RepositoryStatus) { + const newParentShowResults = new Map(); + const newFileStatusesByChange = new Map([ + ["@", status.fileStatuses], + ]); + const newConflictedFilesByChange = new Map>([ + ["@", status.conflictedFiles], + ]); + + // Only check for tracked files in store backends we know. Unknown backends + // may not be suitable for listing all their contents. Otherwise tracked + // files will are set to `null` to signal they are unsupported. + let newTrackedFiles: Set | null = null; + const isKnownStoreBackend = await this.repository.isKnownStoreBackend(); + if (isKnownStoreBackend) { + newTrackedFiles = new Set(); + const trackedFilesList = await this.repository.fileList({ + noIntegrate: true, + }); + for (const t of trackedFilesList) { + const pathParts = t.split(path.sep); + let currentPath = this.repositoryRoot + path.sep; + for (const p of pathParts) { + currentPath += p; + newTrackedFiles.add(currentPath); + currentPath += path.sep; + } + } + } + + const parentShowPromises = status.parentChanges.map( + async (parentChange) => { + const showResult = await this.repository.show(parentChange.changeId, { + noIntegrate: true, + }); + return { changeId: parentChange.changeId, showResult }; + }, + ); + + const parentShowResultsArray = await Promise.all(parentShowPromises); + + for (const { changeId, showResult } of parentShowResultsArray) { + newParentShowResults.set(changeId, showResult); + newFileStatusesByChange.set(changeId, showResult.fileStatuses); + newConflictedFilesByChange.set(changeId, showResult.conflictedFiles); + } + + this.status = status; + this.fileStatusesByChange = newFileStatusesByChange; + this.conflictedFilesByChange = newConflictedFilesByChange; + this.parentShowResults = newParentShowResults; + this.trackedFiles = newTrackedFiles; + } + + static getLabel(prefix: string, change: Change) { + return `${prefix} ${ + change.description ? ` • ${change.description}` : "" + }${change.isEmpty ? " (empty)" : ""}${ + change.isConflict ? " (conflict)" : "" + }${change.description ? "" : " (no description)"}`; + } + + render() { + if (!this.status?.workingCopy) { + throw new Error( + "Cannot render source control without a current working copy change.", + ); + } + + this.workingCopyResourceGroup.label = "Working Copy"; + this.workingCopyResourceGroup.resourceStates = this.status.fileStatuses.map( + (fileStatus) => { + return { + resourceUri: vscode.Uri.file(fileStatus.path), + decorations: { + strikeThrough: fileStatus.type === "D", + tooltip: path.basename(fileStatus.file), + }, + command: getResourceStateCommand( + fileStatus, + toJJUri(vscode.Uri.file(`${fileStatus.path}`), { + diffOriginalRev: "@", + }), + vscode.Uri.file(fileStatus.path), + ), + }; + }, + ); + this.sourceControl.count = this.status.fileStatuses.length; + + const updatedGroups: vscode.SourceControlResourceGroup[] = []; + for (const group of this.parentResourceGroups) { + const parentChange = this.status.parentChanges.find( + (change) => change.changeId === group.id, + ); + if (!parentChange) { + group.dispose(); + } else { + group.label = RepositorySourceControlManager.getLabel( + "Parent Commit", + parentChange, + ); + updatedGroups.push(group); + } + } + this.parentResourceGroups = updatedGroups; + + for (const parentChange of this.status.parentChanges.filter( + (c) => !c.isImmutable, + )) { + let parentChangeResourceGroup!: vscode.SourceControlResourceGroup; + + const parentGroup = this.parentResourceGroups.find( + (group) => group.id === parentChange.changeId, + ); + if (!parentGroup) { + parentChangeResourceGroup = this.sourceControl.createResourceGroup( + parentChange.changeId, + RepositorySourceControlManager.getLabel( + "Parent Commit", + parentChange, + ), + ); + this.parentResourceGroups.push(parentChangeResourceGroup); + } else { + parentChangeResourceGroup = parentGroup; + } + + const showResult = this.parentShowResults.get(parentChange.changeId); + if (showResult) { + parentChangeResourceGroup.resourceStates = showResult.fileStatuses.map( + (parentStatus) => { + return { + resourceUri: toJJUri(vscode.Uri.file(parentStatus.path), { + rev: parentChange.changeId, + }), + decorations: { + strikeThrough: parentStatus.type === "D", + tooltip: path.basename(parentStatus.file), + }, + command: getResourceStateCommand( + parentStatus, + toJJUri(vscode.Uri.file(parentStatus.path), { + diffOriginalRev: parentChange.changeId, + }), + vscode.Uri.file(parentStatus.path), + ), + }; + }, + ); + } + } + + this.decorationProvider.onRefresh( + this.fileStatusesByChange, + this.trackedFiles, + this.conflictedFilesByChange, + ); + } + + dispose() { + for (const subscription of this.subscriptions) { + subscription.dispose(); + } + for (const group of this.parentResourceGroups) { + group.dispose(); + } + } +} diff --git a/src/scm/utils.ts b/src/scm/utils.ts new file mode 100644 index 00000000..21fafae9 --- /dev/null +++ b/src/scm/utils.ts @@ -0,0 +1,48 @@ +import * as vscode from "vscode"; +import { getParams, toJJUri } from "../uri"; +import { FileStatus } from "../jj/types"; + +export function provideOriginalResource(uri: vscode.Uri) { + if (!["file", "jj"].includes(uri.scheme)) { + return undefined; + } + + let rev = "@"; + if (uri.scheme === "jj") { + const params = getParams(uri); + if ("diffOriginalRev" in params) { + // It doesn't make sense to show a quick diff for the left side of a diff. Diffception? + return undefined; + } + rev = params.rev; + } + const filePath = uri.fsPath; + const originalUri = toJJUri(vscode.Uri.file(filePath), { + diffOriginalRev: rev, + }); + + return originalUri; +} + +export function getResourceStateCommand( + fileStatus: FileStatus, + beforeUri: vscode.Uri, + afterUri: vscode.Uri, +): vscode.Command { + if (fileStatus.type === "D") { + return { + title: "Open", + command: "vscode.open", + arguments: [ + beforeUri, + {} satisfies vscode.TextDocumentShowOptions, + `${fileStatus.file} (Deleted)`, + ], + }; + } + return { + title: "Open", + command: "vscode.open", + arguments: [afterUri], + }; +} diff --git a/src/scm/workspace.ts b/src/scm/workspace.ts new file mode 100644 index 00000000..aad4105e --- /dev/null +++ b/src/scm/workspace.ts @@ -0,0 +1,240 @@ +import * as vscode from "vscode"; +import path from "path"; +import { JJDecorationProvider } from "../decorationProvider"; +import { JJFileSystemProvider } from "../fileSystemProvider"; +import { SemVer } from "../semver"; +import { + getJJPath, + getJJVersion, + getConfigArgs, + spawnJJ, + handleCommand, +} from "../jj/cli"; +import { getLogger } from "../logger"; +import { extensionDir } from "../env"; +import { RepositorySourceControlManager } from "./repository"; + +export class WorkspaceSourceControlManager { + repoInfos: + | Map< + string, + { + jjPath: Awaited>; + jjVersion: SemVer; + jjConfigArgs: string[]; + repoRoot: string; + } + > + | undefined; + repoSCMs: RepositorySourceControlManager[] = []; + subscriptions: { + dispose(): unknown; + }[] = []; + fileSystemProvider: JJFileSystemProvider; + + private _onDidRepoUpdate = new vscode.EventEmitter<{ + repoSCM: RepositorySourceControlManager; + }>(); + readonly onDidRepoUpdate: vscode.Event<{ + repoSCM: RepositorySourceControlManager; + }> = this._onDidRepoUpdate.event; + + constructor(private decorationProvider: JJDecorationProvider) { + this.fileSystemProvider = new JJFileSystemProvider(this); + this.subscriptions.push(this.fileSystemProvider); + this.subscriptions.push( + vscode.workspace.registerFileSystemProvider( + "jj", + this.fileSystemProvider, + { + isReadonly: true, + isCaseSensitive: true, + }, + ), + ); + } + + async refresh() { + const newRepoInfos = new Map< + string, + { + jjPath: Awaited>; + jjVersion: SemVer; + jjConfigArgs: string[]; + repoRoot: string; + } + >(); + for (const workspaceFolder of vscode.workspace.workspaceFolders || []) { + try { + const jjPath = await getJJPath(workspaceFolder.uri.fsPath); + const jjVersion = await getJJVersion(jjPath.filepath); + const jjConfigArgs = await getConfigArgs(extensionDir, jjVersion); + + const repoRoot = ( + await handleCommand( + spawnJJ(jjPath.filepath, ["root"], { + timeout: 5000, + cwd: workspaceFolder.uri.fsPath, + }), + ) + ) + .toString() + .trim(); + + const repoUri = vscode.Uri.file( + repoRoot.replace(/^\\\\\?\\UNC\\/, "\\\\"), + ).toString(); + + if (!newRepoInfos.has(repoUri)) { + newRepoInfos.set(repoUri, { + jjPath, + jjVersion, + jjConfigArgs, + repoRoot, + }); + } + } catch (e) { + if (e instanceof Error && e.message.includes("no jj repo in")) { + getLogger().debug(`No jj repo in ${workspaceFolder.uri.fsPath}`); + } else { + getLogger().error( + `Error while initializing ukemi in workspace ${workspaceFolder.uri.fsPath}: ${String(e)}`, + ); + } + continue; + } + } + + let isAnyRepoChanged = false; + for (const [key, value] of newRepoInfos) { + const oldValue = this.repoInfos?.get(key); + if (!oldValue) { + isAnyRepoChanged = true; + getLogger().info(`Detected new jj repo in workspace: ${key}`); + } else if ( + !oldValue.jjVersion.equals(value.jjVersion) || + oldValue.jjPath.filepath !== value.jjPath.filepath || + oldValue.jjConfigArgs.join(" ") !== value.jjConfigArgs.join(" ") || + oldValue.repoRoot !== value.repoRoot + ) { + isAnyRepoChanged = true; + getLogger().info( + `Detected change that requires reinitialization in workspace: ${key}`, + ); + } + } + for (const key of this.repoInfos?.keys() || []) { + if (!newRepoInfos.has(key)) { + isAnyRepoChanged = true; + getLogger().info(`Detected jj repo removal in workspace: ${key}`); + } + } + this.repoInfos = newRepoInfos; + + if (isAnyRepoChanged) { + const repoSCMs: RepositorySourceControlManager[] = []; + for (const [ + workspaceFolder, + { repoRoot, jjPath, jjVersion, jjConfigArgs }, + ] of newRepoInfos.entries()) { + getLogger().info( + `Initializing ukemi in workspace ${workspaceFolder}. Using ${jjVersion.toString()} at ${jjPath.filepath} (${jjPath.source}).`, + ); + const repoSCM = new RepositorySourceControlManager( + repoRoot, + this.decorationProvider, + this.fileSystemProvider, + jjPath.filepath, + jjVersion, + jjConfigArgs, + ); + repoSCM.onDidUpdate( + () => { + this._onDidRepoUpdate.fire({ repoSCM }); + }, + undefined, + repoSCM.subscriptions, + ); + repoSCMs.push(repoSCM); + } + + for (const repoSCM of this.repoSCMs) { + repoSCM.dispose(); + } + this.repoSCMs = repoSCMs; + } + return isAnyRepoChanged; + } + + getRepositoryFromUri(uri: vscode.Uri) { + return this.repoSCMs.find((repo) => { + return !path.relative(repo.repositoryRoot, uri.fsPath).startsWith(".."); + })?.repository; + } + + getRepositoryFromResourceGroup( + resourceGroup: vscode.SourceControlResourceGroup, + ) { + return this.repoSCMs.find((repo) => { + return ( + resourceGroup === repo.workingCopyResourceGroup || + repo.parentResourceGroups.includes(resourceGroup) + ); + })?.repository; + } + + getRepositoryFromSourceControl(sourceControl: vscode.SourceControl) { + return this.repoSCMs.find((repo) => repo.sourceControl === sourceControl) + ?.repository; + } + + getRepositorySourceControlManagerFromUri(uri: vscode.Uri) { + return this.repoSCMs.find((repo) => { + return !path.relative(repo.repositoryRoot, uri.fsPath).startsWith(".."); + }); + } + + getRepositorySourceControlManagerFromResourceGroup( + resourceGroup: vscode.SourceControlResourceGroup, + ) { + return this.repoSCMs.find( + (repo) => + repo.workingCopyResourceGroup === resourceGroup || + repo.parentResourceGroups.includes(resourceGroup), + ); + } + + getResourceGroupFromResourceState( + resourceState: vscode.SourceControlResourceState, + ) { + const resourceUri = resourceState.resourceUri; + + for (const repo of this.repoSCMs) { + const groups = [ + repo.workingCopyResourceGroup, + ...repo.parentResourceGroups, + ]; + + for (const group of groups) { + if ( + group.resourceStates.some( + (state) => state.resourceUri.toString() === resourceUri.toString(), + ) + ) { + return group; + } + } + } + + throw new Error("Resource state not found in any resource group"); + } + + dispose() { + for (const subscription of this.repoSCMs) { + subscription.dispose(); + } + for (const subscription of this.subscriptions) { + subscription.dispose(); + } + } +} diff --git a/src/test/all-tests.ts b/src/test/all-tests.ts index c85e65d0..d784d884 100644 --- a/src/test/all-tests.ts +++ b/src/test/all-tests.ts @@ -2,5 +2,5 @@ // so they are included in the single bundle that Mocha will run. import "./main.test"; -import "./repository.test"; +import "./jj/repository.test"; import "./fakeeditor.test"; diff --git a/src/test/fakeeditor.test.ts b/src/test/fakeeditor.test.ts index ad68c9d5..2dda8359 100644 --- a/src/test/fakeeditor.test.ts +++ b/src/test/fakeeditor.test.ts @@ -3,7 +3,7 @@ import * as path from "path"; import * as os from "os"; import * as fs from "fs"; import { execPromise } from "./utils"; -import { fakeEditorPath, initExtensionDir } from "../repository"; +import { fakeEditorPath, initExtensionDir } from "../env"; import * as vscode from "vscode"; import { ExecException, spawn } from "child_process"; diff --git a/src/test/repository.test.ts b/src/test/jj/repository.test.ts similarity index 96% rename from src/test/repository.test.ts rename to src/test/jj/repository.test.ts index 56bddffb..3c59f6f1 100644 --- a/src/test/repository.test.ts +++ b/src/test/jj/repository.test.ts @@ -1,16 +1,11 @@ +import { parseRenamePaths } from "../../jj/parser"; import * as assert from "assert/strict"; -import { - parseRenamePaths, - JJRepository, - Change, - FileStatus, - Show, - ChangeWithDetails, -} from "../repository"; // Adjust path as needed -import { getJJPath, getRepoAuthor, getRepoPath } from "./utils"; +import { JJRepository } from "../../jj/repository"; +import { Change, FileStatus, Show, ChangeWithDetails } from "../../jj/types"; +import { getJJPath, getRepoAuthor, getRepoPath } from "../utils"; import fs from "fs/promises"; import path from "path"; -import { SemVer } from "../semver"; +import { SemVer } from "../../semver"; suite("JJRepository", () => { let suiteDir: string; diff --git a/src/webview/graph.css b/src/webview/graph.css index b72870a4..2be959cf 100644 --- a/src/webview/graph.css +++ b/src/webview/graph.css @@ -194,7 +194,6 @@ body { display: inline; } - .commit-message { line-height: 1.2; font-weight: normal; diff --git a/src/webview/graph.html b/src/webview/graph.html index c4b6bf22..02627b47 100644 --- a/src/webview/graph.html +++ b/src/webview/graph.html @@ -1,615 +1,730 @@ - + + + + + - - - - - - +
- - - - - -
+ + + + + +
- + // Reapply any existing classes (like child-node or dimmed) + const hoveredNode = document.querySelector(".change-node:hover"); + if (hoveredNode && selectedNodes.size === 0) { + highlightConnectedNodes(hoveredNode, true); + } + } + + // Update the resize handler + let resizeTimeout; + window.addEventListener("resize", () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + requestAnimationFrame(() => { + updateConnections(); + updateCirclePositions(); + }); + }, 2); + }); + + // Signal that the webview is ready + window.addEventListener("load", () => { + vscode.postMessage({ command: "webviewReady" }); + }); + + diff --git a/syntaxes/jj-commit.tmLanguage.json b/syntaxes/jj-commit.tmLanguage.json index 6a858878..fe98e1da 100644 --- a/syntaxes/jj-commit.tmLanguage.json +++ b/syntaxes/jj-commit.tmLanguage.json @@ -1,60 +1,60 @@ -{ - "name": "JJ Commit Message", - "scopeName": "text.jj-commit", - "patterns": [ - { - "comment": "User supplied message", - "name": "meta.scope.message.jj-commit", - "begin": "^(?!JJ:)", - "end": "^(?=JJ:)", - "patterns": [ - { - "comment": "Mark > 50 lines as deprecated, > 72 as illegal", - "name": "meta.scope.subject.jj-commit", - "match": "\\G.{0,50}(.{0,22}(.*))$", - "captures": { - "1": { - "name": "invalid.deprecated.line-too-long.jj-commit" - }, - "2": { - "name": "invalid.illegal.line-too-long.jj-commit" - } - } - } - ] - }, - { - "comment": "JJ supplied metadata in a number of lines starting with JJ:", - "name": "meta.scope.metadata.jj-commit", - "begin": "^(?=JJ:)", - "contentName": "comment.line.indicator.jj-commit", - "end": "^(?!JJ:)", - "patterns": [ - { - "match": "^JJ:\\s+((M|R) .*)$", - "captures": { - "1": { - "name": "markup.changed.jj-commit" - } - } - }, - { - "match": "^JJ:\\s+(A .*)$", - "captures": { - "1": { - "name": "markup.inserted.jj-commit" - } - } - }, - { - "match": "^JJ:\\s+(D .*)$", - "captures": { - "1": { - "name": "markup.deleted.jj-commit" - } - } - } - ] - } - ] -} \ No newline at end of file +{ + "name": "JJ Commit Message", + "scopeName": "text.jj-commit", + "patterns": [ + { + "comment": "User supplied message", + "name": "meta.scope.message.jj-commit", + "begin": "^(?!JJ:)", + "end": "^(?=JJ:)", + "patterns": [ + { + "comment": "Mark > 50 lines as deprecated, > 72 as illegal", + "name": "meta.scope.subject.jj-commit", + "match": "\\G.{0,50}(.{0,22}(.*))$", + "captures": { + "1": { + "name": "invalid.deprecated.line-too-long.jj-commit" + }, + "2": { + "name": "invalid.illegal.line-too-long.jj-commit" + } + } + } + ] + }, + { + "comment": "JJ supplied metadata in a number of lines starting with JJ:", + "name": "meta.scope.metadata.jj-commit", + "begin": "^(?=JJ:)", + "contentName": "comment.line.indicator.jj-commit", + "end": "^(?!JJ:)", + "patterns": [ + { + "match": "^JJ:\\s+((M|R) .*)$", + "captures": { + "1": { + "name": "markup.changed.jj-commit" + } + } + }, + { + "match": "^JJ:\\s+(A .*)$", + "captures": { + "1": { + "name": "markup.inserted.jj-commit" + } + } + }, + { + "match": "^JJ:\\s+(D .*)$", + "captures": { + "1": { + "name": "markup.deleted.jj-commit" + } + } + } + ] + } + ] +}