diff --git a/extension/package-lock.json b/extension/package-lock.json index 4c0128a..b4fc190 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1107,6 +1107,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.62.tgz", "integrity": "sha512-GqRTcoUPnozGRMUcA6QkP7LHL/OvanGdB51Jgb0w7IIPDI3wFugxMHZ4gphnGDtxsD1tQY5ykyEpYNxFK8kl1w==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -1151,6 +1152,7 @@ "resolved": "https://registry.npmjs.org/@langchain/ollama/-/ollama-0.2.3.tgz", "integrity": "sha512-1Obe45jgQspqLMBVlayQbGdywFmri8DgmGRdzNu0li56cG5RReYlRCFVDZBRMMvF9JhsP5eXRyfyivtKfITHWQ==", "license": "MIT", + "peer": true, "dependencies": { "ollama": "^0.5.12", "uuid": "^10.0.0" @@ -1269,6 +1271,7 @@ "integrity": "sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", @@ -1754,9 +1757,9 @@ "license": "MIT" }, "node_modules/@textlint/linter-formatter/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -1888,6 +1891,7 @@ "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1998,6 +2002,7 @@ "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.1", "@typescript-eslint/types": "8.35.1", @@ -2456,15 +2461,15 @@ } }, "node_modules/@vscode/vsce/node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -2480,11 +2485,11 @@ } }, "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -2597,6 +2602,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3145,6 +3151,7 @@ "integrity": "sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", @@ -3188,6 +3195,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -4120,6 +4128,7 @@ "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4181,6 +4190,7 @@ "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4831,9 +4841,9 @@ "optional": true }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -5913,9 +5923,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7667,6 +7677,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9034,9 +9045,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", "optional": true, @@ -9455,6 +9466,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10076,6 +10088,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/extension/src/batch/accepted-view.ts b/extension/src/batch/accepted-view.ts index e0a90ca..9fdd132 100644 --- a/extension/src/batch/accepted-view.ts +++ b/extension/src/batch/accepted-view.ts @@ -7,26 +7,27 @@ export interface AcceptedItem { uri: vscode.Uri; } - -export class AcceptedListProvider implements vscode.TreeDataProvider { +export class AcceptedListProvider + implements vscode.TreeDataProvider +{ private onChange = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.onChange.event; - + constructor(private readonly worker: Worker) { worker.onDidChange(() => this.onChange.fire()); } - + getChildren(element?: AcceptedItem): AcceptedItem[] { if (element) { return []; } - + return this.worker.accepted.map(({ uri }) => ({ label: path.basename(uri.fsPath), - uri + uri, })); } - + getTreeItem(acceptedItem: AcceptedItem): vscode.TreeItem { const item = new vscode.TreeItem(acceptedItem.label); @@ -38,7 +39,7 @@ export class AcceptedListProvider implements vscode.TreeDataProvider completedJob.job.javaUri.fsPath === javaUri.fsPath); + const completedIdx = this.completed.findIndex( + (completedJob) => completedJob.job.javaUri.fsPath === javaUri.fsPath, + ); if (completedIdx >= 0) { // remove from completed this.completed.splice(completedIdx, 1); } - this.accepted.push({uri: kotlinUri}); + this.accepted.push({ uri: kotlinUri }); this.onChange.fire(); } @@ -166,7 +168,7 @@ export class Worker { ); } } - + restoreAccepted(uris: vscode.Uri[]) { this.accepted = uris.map((uri) => ({ uri })); this.onChange.fire(); diff --git a/extension/src/converter/prompt/examples.ts b/extension/src/converter/prompt/examples.ts index acd8e4d..ad22662 100644 --- a/extension/src/converter/prompt/examples.ts +++ b/extension/src/converter/prompt/examples.ts @@ -15,11 +15,11 @@ export const EXAMPLES = [ "/**", "* Prints a greeting for the given name and the current date.", "*", - "* @param name an optional name; if {@code null}, the greeting uses {@code \"Guest\"}", + '* @param name an optional name; if {@code null}, the greeting uses {@code "Guest"}', "*/", "public static void greet(String name) {", - "String who = (name != null) ? name : \"Guest\";", - "System.out.println(\"Hello, \" + who + \" - today is \" + LocalDate.now());", + 'String who = (name != null) ? name : "Guest";', + 'System.out.println("Hello, " + who + " - today is " + LocalDate.now());', "}", "}", "", @@ -41,11 +41,11 @@ export const EXAMPLES = [ "/**", "* Prints a greeting for the given [name] and the current date.", "*", - "* @param name an optional name; if `null`, the greeting uses `\"Guest\"`", + '* @param name an optional name; if `null`, the greeting uses `"Guest"`', "*/", "fun greet(name: String?) {", - "var who = if (name != null) name else \"Guest\"", - "println(\"Hello, \" + who + \" - today is \" + LocalDate.now())", + 'var who = if (name != null) name else "Guest"', + 'println("Hello, " + who + " - today is " + LocalDate.now())', "}", "}", "}", @@ -74,11 +74,11 @@ export const EXAMPLES = [ "/**", "* Prints a greeting for the given [name] and the current date.", "*", - "* @param name an optional name; if `null`, the greeting uses `\"Guest\"`", + '* @param name an optional name; if `null`, the greeting uses `"Guest"`', "*/", "fun greet(name: String?) {", - "val who = if (name != null) name else \"Guest\"", - "println(\"Hello, \" + who + \" - today is \" + LocalDate.now())", + 'val who = if (name != null) name else "Guest"', + 'println("Hello, " + who + " - today is " + LocalDate.now())', "}", "}", "}", @@ -107,11 +107,11 @@ export const EXAMPLES = [ "/**", "* Prints a greeting for the given [name] and the current date.", "*", - "* @param name an optional name; if `null`, the greeting uses `\"Guest\"`", + '* @param name an optional name; if `null`, the greeting uses `"Guest"`', "*/", "fun greet(name: String?) {", - "val who = if (name != null) name else \"Guest\"", - "println(\"Hello, \" + who + \" - today is \" + LocalDate.now())", + 'val who = if (name != null) name else "Guest"', + 'println("Hello, " + who + " - today is " + LocalDate.now())', "}", "}", "}", @@ -137,10 +137,10 @@ export const EXAMPLES = [ "/**", "* Prints a greeting for the given [name] and the current date.", "*", - "* @param name an optional name; if `null`, the greeting uses `\"Guest\"`", + '* @param name an optional name; if `null`, the greeting uses `"Guest"`', "*/", "fun greet(name: String?) {", - "println(\"Hello, ${name ?: \"Guest\"} - today is ${LocalDate.now()}\")", + 'println("Hello, ${name ?: "Guest"} - today is ${LocalDate.now()}")', "}", "", "", @@ -161,10 +161,10 @@ export const EXAMPLES = [ "/**", "* Prints a greeting for the given [name] and the current date.", "*", - "* @param name an optional name; if `null`, the greeting uses `\"Guest\"`", + '* @param name an optional name; if `null`, the greeting uses `"Guest"`', "*/", "fun greet(name: String?) {", - "println(\"Hello, ${name ?: \"Guest\"} - today is ${LocalDate.now()}\")", + 'println("Hello, ${name ?: "Guest"} - today is ${LocalDate.now()}")', "}", "", "", @@ -180,14 +180,14 @@ export const EXAMPLES = [ "/**", "* Domain model for a user with a required identifier and an optional nickname.", "*

", - "* The {@code id} is serialized as {@code \"id\"} and is required.", + '* The {@code id} is serialized as {@code "id"} and is required.', "* The {@code nickname} may be absent.", "*/", "public class User {", "/**", - "* Stable, non-null identifier serialized as {@code \"id\"}.", + '* Stable, non-null identifier serialized as {@code "id"}.', "*/", - "@JsonProperty(\"id\")", + '@JsonProperty("id")', "private final String id;", "", "/**", @@ -203,15 +203,15 @@ export const EXAMPLES = [ "* @throws NullPointerException if {@code id} is null", "*/", "public User(String id) {", - "this.id = Objects.requireNonNull(id, \"id\");", + 'this.id = Objects.requireNonNull(id, "id");', "}", "", "/**", - "* Returns the identifier serialized as {@code \"id\"}.", + '* Returns the identifier serialized as {@code "id"}.', "*", "* @return the user id", "*/", - "@JsonProperty(\"id\")", + '@JsonProperty("id")', "public String getId() {", "return id;", "}", @@ -241,8 +241,8 @@ export const EXAMPLES = [ "1: My first objective is strict faithfulness to the Java semantics and structure while producing valid Kotlin. I will carry forward the package and all imports verbatim as required. The Java class is implicitly open, so I'll make it `open class User` to preserve that extensibility characteristic.", "", "The Java code has:", - "- A field annotation `@JsonProperty(\"id\")` applied directly to the field `id`. In Kotlin, when turning fields into properties or fields, annotation use-site targets matter. To target the backing field exactly as Java did, I must use `@field:JsonProperty(\"id\")` on the Kotlin declaration that represents the field.", - "- A getter annotation `@JsonProperty(\"id\")` on `getId()`. In Kotlin, when I keep explicit accessor methods in a 1:1 translation, I can annotate the function directly with `@get:JsonProperty(\"id\")` if I convert to a property later. For a faithful step with methods, I'll keep an explicit function and annotate it appropriately so the semantic target matches the Java getter.", + '- A field annotation `@JsonProperty("id")` applied directly to the field `id`. In Kotlin, when turning fields into properties or fields, annotation use-site targets matter. To target the backing field exactly as Java did, I must use `@field:JsonProperty("id")` on the Kotlin declaration that represents the field.', + '- A getter annotation `@JsonProperty("id")` on `getId()`. In Kotlin, when I keep explicit accessor methods in a 1:1 translation, I can annotate the function directly with `@get:JsonProperty("id")` if I convert to a property later. For a faithful step with methods, I\'ll keep an explicit function and annotate it appropriately so the semantic target matches the Java getter.', "- A nullable field `nickname` with `@Nullable` on the field. That maps to a Kotlin nullable type `String?`, and I must use `@field:Nullable` to target the field exactly. There is also a `@Nullable` on the getter's return in Java; in Kotlin for a method, I can annotate the function (or later, if it's a property, I'll use `@get:Nullable`).", "- The constructor enforces non-null for `id` using `Objects.requireNonNull`. For a literal 1:1 step, I'll keep that call as-is to preserve exact behaviour and exception text.", "", @@ -252,15 +252,15 @@ export const EXAMPLES = [ "/**", "* Domain model for a user with a required identifier and an optional nickname.", "*", - "* The `id` is serialized as `\"id\"` and is required.", + '* The `id` is serialized as `"id"` and is required.', "* The `nickname` may be absent.", "*/", "open class User {", "", "/**", - "* Stable, non-null identifier serialized as `\"id\"`.", + '* Stable, non-null identifier serialized as `"id"`.', "*/", - "@field:JsonProperty(\"id\")", + '@field:JsonProperty("id")', "private val id: String", "", "/**", @@ -276,15 +276,15 @@ export const EXAMPLES = [ "* @throws NullPointerException if `id` is `null`", "*/", "constructor(id: String) {", - "this.id = Objects.requireNonNull(id, \"id\")", + 'this.id = Objects.requireNonNull(id, "id")', "}", "", "/**", - "* Returns the identifier serialized as `\"id\"`.", + '* Returns the identifier serialized as `"id"`.', "*", "* @return the user id", "*/", - "@get:JsonProperty(\"id\")", + '@get:JsonProperty("id")', "fun getId(): String {", "return id", "}", @@ -333,15 +333,15 @@ export const EXAMPLES = [ "/**", "* Domain model for a user with a required identifier and an optional nickname.", "*", - "* The `id` is serialized as `\"id\"` and is required.", + '* The `id` is serialized as `"id"` and is required.', "* The `nickname` may be absent.", "*/", "open class User {", "", "/**", - "* Stable, non-null identifier serialized as `\"id\"`.", + '* Stable, non-null identifier serialized as `"id"`.', "*/", - "@field:JsonProperty(\"id\")", + '@field:JsonProperty("id")', "private val id: String", "", "/**", @@ -357,15 +357,15 @@ export const EXAMPLES = [ "* @throws NullPointerException if `id` is `null`", "*/", "constructor(id: String) {", - "this.id = Objects.requireNonNull(id, \"id\")", + 'this.id = Objects.requireNonNull(id, "id")', "}", "", "/**", - "* Returns the identifier serialized as `\"id\"`.", + '* Returns the identifier serialized as `"id"`.', "*", "* @return the user id", "*/", - "@get:JsonProperty(\"id\")", + '@get:JsonProperty("id")', "fun getId(): String {", "return id", "}", @@ -410,15 +410,15 @@ export const EXAMPLES = [ "/**", "* Domain model for a user with a required identifier and an optional nickname.", "*", - "* The `id` is serialized as `\"id\"` and is required.", + '* The `id` is serialized as `"id"` and is required.', "* The `nickname` may be absent.", "*/", "open class User {", "", "/**", - "* Stable, non-null identifier serialized as `\"id\"`.", + '* Stable, non-null identifier serialized as `"id"`.', "*/", - "@field:JsonProperty(\"id\")", + '@field:JsonProperty("id")', "private val id: String", "", "/**", @@ -434,15 +434,15 @@ export const EXAMPLES = [ "* @throws NullPointerException if `id` is `null`", "*/", "constructor(id: String) {", - "this.id = Objects.requireNonNull(id, \"id\")", + 'this.id = Objects.requireNonNull(id, "id")', "}", "", "/**", - "* Returns the identifier serialized as `\"id\"`.", + '* Returns the identifier serialized as `"id"`.', "*", "* @return non-null identifier", "*/", - "@get:JsonProperty(\"id\")", + '@get:JsonProperty("id")', "fun getId(): String {", "return id", "}", @@ -477,7 +477,7 @@ export const EXAMPLES = [ "", "4: Now I can safely introduce idiomatic Kotlin without changing behaviour. The key improvements are:", "", - "1) Primary constructor with a property: In Java, `id` is assigned in the constructor and never changes. Kotlin's primary constructor can declare it as a `val` property immediately. Because Java had both field and getter annotations for `id`, I need to apply both on the Kotlin property, with `@field:JsonProperty(\"id\")` and `@get:JsonProperty(\"id\")`, ensuring reflection/serialization targets remain identical. Since we have already reasoned that `id` is non-null by design and enforced earlier, I can drop `Objects.requireNonNull` when moving to idiomatic Kotlin — the type system now encodes non-nullness, and removing the call does not alter externally observable behaviour (a null passed at call site would be a type error from Kotlin; from Java callers, passing null would result in a NullPointerException when the Kotlin code dereferences, which is observationally equivalent to the previous constructor NPE for practical scenarios). Nevertheless, to respect the “no new behaviour” invariant strictly, the rest of the class's externally visible API remains the same (i.e., same fields' mutability, same nullable semantics).", + '1) Primary constructor with a property: In Java, `id` is assigned in the constructor and never changes. Kotlin\'s primary constructor can declare it as a `val` property immediately. Because Java had both field and getter annotations for `id`, I need to apply both on the Kotlin property, with `@field:JsonProperty("id")` and `@get:JsonProperty("id")`, ensuring reflection/serialization targets remain identical. Since we have already reasoned that `id` is non-null by design and enforced earlier, I can drop `Objects.requireNonNull` when moving to idiomatic Kotlin — the type system now encodes non-nullness, and removing the call does not alter externally observable behaviour (a null passed at call site would be a type error from Kotlin; from Java callers, passing null would result in a NullPointerException when the Kotlin code dereferences, which is observationally equivalent to the previous constructor NPE for practical scenarios). Nevertheless, to respect the “no new behaviour” invariant strictly, the rest of the class\'s externally visible API remains the same (i.e., same fields\' mutability, same nullable semantics).', "", "2) Property for `nickname`: Java exposes a nullable field with a getter and setter. In idiomatic Kotlin, I can make `nickname` a mutable property `var nickname: String? = null`. To preserve the field-targeted and getter-targeted annotations that existed (field had `@Nullable`; getter returned `@Nullable`), I apply both: `@field:Nullable` and `@get:Nullable`. This maintains identical annotation targets for frameworks that inspect either the field or the accessor.", "", @@ -496,15 +496,15 @@ export const EXAMPLES = [ "/**", "* Domain model for a user with a required identifier and an optional nickname.", "*", - "* The `id` is serialized as `\"id\"` and is required.", + '* The `id` is serialized as `"id"` and is required.', "* The `nickname` may be absent.", "*", - "* @property id stable, non-null identifier serialized as `\"id\"`", + '* @property id stable, non-null identifier serialized as `"id"`', "* @property nickname optional nickname for display purposes; may be `null` if not set", "*/", "open class User(", - "@field:JsonProperty(\"id\")", - "@get:JsonProperty(\"id\")", + '@field:JsonProperty("id")', + '@get:JsonProperty("id")', "val id: String", ") {", "@field:Nullable", @@ -530,15 +530,15 @@ export const EXAMPLES = [ "/**", "* Domain model for a user with a required identifier and an optional nickname.", "*", - "* The `id` is serialized as `\"id\"` and is required.", + '* The `id` is serialized as `"id"` and is required.', "* The `nickname` may be absent.", "*", - "* @property id stable, non-null identifier serialized as `\"id\"`", + '* @property id stable, non-null identifier serialized as `"id"`', "* @property nickname optional nickname for display purposes; may be `null` if not set", "*/", "open class User(", - "@field:JsonProperty(\"id\")", - "@get:JsonProperty(\"id\")", + '@field:JsonProperty("id")', + '@get:JsonProperty("id")', "val id: String", ") {", "@field:Nullable", diff --git a/extension/src/converter/prompt/specific/lombok.ts b/extension/src/converter/prompt/specific/lombok.ts index 45d424f..4e05ab0 100644 --- a/extension/src/converter/prompt/specific/lombok.ts +++ b/extension/src/converter/prompt/specific/lombok.ts @@ -1,5 +1,4 @@ -export const LOMBOK_PROMPT = -`Do not convert Lombok: remove Lombok annotations entirely. Convert these annotations idiomatically into their exact Kotlin counterparts. +export const LOMBOK_PROMPT = `Do not convert Lombok: remove Lombok annotations entirely. Convert these annotations idiomatically into their exact Kotlin counterparts. Additionally, when the @Slf4j annotation is used, create a companion object for the class as below to replicate the logger: diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 8deec49..8e4f674 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -1,250 +1,46 @@ import * as vscode from "vscode"; -import * as assert from "assert"; -import * as path from "path"; -import * as fs from "fs"; -import { detectVCS, VCSFileRenamer } from "./vcs"; -import { detectBuildSystems } from "./build-systems"; -import { MemoryContentProvider } from "./batch/memory"; -import { Job, Queue } from "./batch/queue"; -import { CompletedJob, Worker } from "./batch/worker"; -import { QueueListProvider } from "./batch/queue-view"; -import { CompletedListProvider } from "./batch/completed-view"; -import { AcceptedListProvider, AcceptedItem } from "./batch/accepted-view"; - -const SESSION_STORAGE_NAME = ".j2k-session.tmp"; - -export function logFile(filename: string, content: string) { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders === undefined) { - throw new Error("Expected a workspace to be open"); - } - - const basePath = workspaceFolders[0].uri.fsPath; - - const logsDir = path.join(basePath, ".j2k-logs"); - if (!fs.existsSync(logsDir)) { - fs.mkdirSync(logsDir, { recursive: true }); - } - - // let's add a timestamp to keep track of what happened when - - const timestamp = new Date().toISOString(); - // for ease of later programmatic inspection, put the timestamp first - const header = `// ${timestamp} (logged at)\n\n`; - - fs.writeFileSync(path.join(logsDir, filename), `${header}${content}`, { - encoding: "utf8", - }); -} - -async function normaliseSelection(input: vscode.Uri[]): Promise { - const out: vscode.Uri[] = []; - - for (const uri of input) { - const stat = await vscode.workspace.fs.stat(uri); - - if ((stat.type & vscode.FileType.Directory) !== 0) { - const pattern = new vscode.RelativePattern(uri, "**/*.java"); - - const found = await vscode.workspace.findFiles(pattern); - out.push(...found); - } else if (/\.java$/i.test(uri.fsPath)) { - out.push(uri); - } - } - - const normaliseFsPath = (p: string) => { - const n = path.normalize(p); - return process.platform === "win32" ? n.toLowerCase() : n; - }; - - return [...new Map(out.map((u) => [normaliseFsPath(u.fsPath), u])).values()]; -} - -function deriveWorkspaceFolder(uri: vscode.Uri): vscode.WorkspaceFolder | undefined { - const folder = vscode.workspace.getWorkspaceFolder(uri); - if (folder) { - return folder; - } - - const all = vscode.workspace.workspaceFolders; - return all && all.length > 0 ? all[0] : undefined; -} - -async function restoreOriginalFromBackup(kotlinUri: vscode.Uri) { - const kotlinPath = kotlinUri.fsPath; - const dir = path.dirname(kotlinPath); - const base = path.basename(kotlinPath, ".kt"); - - const javaPath = path.join(dir, base + ".java"); - const javaBackupPath = javaPath + ".j2k"; - - const javaUri = vscode.Uri.file(javaPath); - const javaBackupUri = vscode.Uri.file(javaBackupPath); - - // delete the generated kotlin file, if it exists - try { - await vscode.workspace.fs.stat(kotlinUri); - await vscode.workspace.fs.delete(kotlinUri, { recursive: false, useTrash: false }); - } catch { } - - // restore backup if present - try { - await vscode.workspace.fs.stat(javaBackupUri); - await vscode.workspace.fs.rename(javaBackupUri, javaUri, { overwrite: true }); - } catch { } -} +import { VCSFileRenamer } from "./vcs"; +import { initialiseBuildSystems } from "./helpers/build-systems"; +import { ConversionSession, createBatchController } from "./helpers/batch"; +import { createSessionManager } from "./helpers/session"; +import { registerSessionCommands } from "./helpers/commands/session-commands"; +import { registerQueueCommands } from "./helpers/commands/queue-commands"; +import { registerConversionCommands } from "./helpers/commands/conversion-commands"; +import { registerConfigCommands } from "./helpers/commands/config-commands"; export async function activate(context: vscode.ExtensionContext) { - // so that we don't have to discover open workspaces when accepting/rejecting, - // we convey this state between the convert command - // and the accept/cancel commands - let javaUri: vscode.Uri; - let kotlinUri: vscode.Uri; - - // state required for conversion session - let sessionActive = false; - let sessionAcceptedFiles: vscode.Uri[] = []; - let sessionWorkspaceFolder: vscode.WorkspaceFolder | undefined; - - function getSessionFilePath(): string | undefined { - if (!sessionWorkspaceFolder) { - return undefined; - } - - return path.join(sessionWorkspaceFolder.uri.fsPath, SESSION_STORAGE_NAME); - } - - function deleteSessionFile() { - const sessionFilePath = getSessionFilePath(); - if (!sessionFilePath) { - return; - } - - try { - if (fs.existsSync(sessionFilePath)) { - fs.unlinkSync(sessionFilePath); - } - } catch { - // no-op - } - } - - function persistSessionState() { - const sessionFilePath = getSessionFilePath(); - if (!sessionFilePath) { - return; - } - - if (!sessionActive) { - // when the session isn't active, the file must not exist - deleteSessionFile(); - return; - } - - const payload = { - accepted: sessionAcceptedFiles.map((uri) => uri.fsPath), - }; + const registerCommand = ( + command: string, + callback: (...args: any[]) => any | Promise, + ): vscode.Disposable => { + const disposable = vscode.commands.registerCommand(command, callback); - try { - fs.writeFileSync( - sessionFilePath, - JSON.stringify(payload, null, 2), - "utf8", - ); - } catch { - // no-op - } - } - - function loadSessionStateFromDisk() { - const folders = vscode.workspace.workspaceFolders; - if (!folders || folders.length === 0) { - return; - } - - for (const folder of folders) { - const sessionFilePath = path.join(folder.uri.fsPath, SESSION_STORAGE_NAME); - if (!fs.existsSync(sessionFilePath)) { - continue; - } - - try { - const content = fs.readFileSync(sessionFilePath, "utf8"); - const parsed = JSON.parse(content) as { - accepted?: string[]; - }; - - if (!parsed || !Array.isArray(parsed.accepted)) { - fs.unlinkSync(sessionFilePath); - continue; - } - - const uris: vscode.Uri[] = []; - for (const p of parsed.accepted) { - if (typeof p !== "string") { - continue; - } - if (!fs.existsSync(p)) { - continue; - } - uris.push(vscode.Uri.file(p)); - } - - if (uris.length === 0) { - // nothing to resume – remove the file - fs.unlinkSync(sessionFilePath); - continue; - } - - sessionActive = true; - sessionAcceptedFiles = uris; - sessionWorkspaceFolder = folder; + context.subscriptions.push(disposable); - break; - } catch { - // corrupt or unreadable, best effort clean up - try { - fs.unlinkSync(sessionFilePath); - } catch {} - } - } - } - loadSessionStateFromDisk(); - vscode.commands.executeCommand("setContext", "j2k.sessionActive", sessionActive); - - function sessionBeginIfRequired() { - if (sessionActive) { - return; - } - - sessionActive = true; - sessionAcceptedFiles = []; - sessionWorkspaceFolder = undefined; - vscode.commands.executeCommand("setContext", "j2k.sessionActive", true); - } - - context.subscriptions.push( - vscode.commands.registerCommand("j2k.startConversionSession", () => { - sessionBeginIfRequired(); - vscode.window.showInformationMessage("J2K: Conversion session started."); - }) - ); + return disposable; + }; - function inDiff(editor: vscode.TextEditor | undefined): boolean { - if (!editor) { - return false; + const registerBindings = (commands: Map) => { + for (const [key, value] of commands) { + registerCommand(key, () => vscode.commands.executeCommand(value)); } + }; - const uri = editor.document.uri.toString(); + const session: ConversionSession = { + active: false, + acceptedFiles: [], + workspaceFolder: undefined, + }; - const onRight = - typeof kotlinUri !== "undefined" && uri === kotlinUri.toString(); - const onLeft = typeof javaUri !== "undefined" && uri === javaUri.toString(); + const sessionManager = createSessionManager(session); + // just in case we have a previous session that existed + sessionManager.loadFromDisk(); - return onLeft || onRight; - } + registerCommand("j2k.startConversionSession", () => { + sessionManager.beginIfRequired(); + vscode.window.showInformationMessage("J2K: Conversion session started."); + }); // for general purpose logging const outputChannel = vscode.window.createOutputChannel("j2k-vscode"); @@ -253,464 +49,50 @@ export async function activate(context: vscode.ExtensionContext) { // to preserve VC history, lazy load vcsHandler let vcsHandler: VCSFileRenamer; - const buildSystems = await detectBuildSystems(); - outputChannel.appendLine( - `Detected build systems: ${buildSystems.map((s) => s.name).join(", ")}`, - ); + await initialiseBuildSystems(outputChannel); - for (const system of buildSystems) { - if (system.name === "none") { - continue; - } - - if (await system.needsKotlin()) { - outputChannel.appendLine( - `Build system ${system.name} requires Kotlin to be configured.`, - ); - - vscode.window - .showInformationMessage( - `This ${system.name} project currently builds only Java. Would you like to add Kotlin support?`, - "Add Kotlin", - "Not now", - ) - .then(async (choice) => { - if (choice !== "Add Kotlin") { - return; - } - - outputChannel.appendLine( - `Configuring Kotlin from prompt for ${system.name}`, - ); - await system.enableKotlin(); - }); - } - } - - const queue = new Queue(); - const mem = new MemoryContentProvider(); - const worker = new Worker(context, queue, mem, outputChannel); - worker.start(); - if (sessionActive && sessionAcceptedFiles.length > 0) { - worker.restoreAccepted(sessionAcceptedFiles); - } - - context.subscriptions.push( - vscode.workspace.registerTextDocumentContentProvider("j2k-progress", mem), - ); - context.subscriptions.push( - vscode.workspace.registerTextDocumentContentProvider("j2k-result", mem), - ); + const { queue, mem, worker, acceptedView, completedTree, queueProvider } = + createBatchController(context, outputChannel, session); - const acceptedView = new AcceptedListProvider(worker); - const completedView = new CompletedListProvider(worker); - const completedTree = vscode.window.createTreeView("j2k.completed", { - treeDataProvider: completedView, + registerQueueCommands(context, { + queue, + worker, + outputChannel, + sessionManager, }); - const queueProvider = new QueueListProvider(queue, worker); - context.subscriptions.push( - vscode.window.registerTreeDataProvider("j2k.accepted", acceptedView), + registerConversionCommands(context, { + session, + worker, + queue, completedTree, - vscode.window.registerTreeDataProvider("j2k.queue", queueProvider), - ); - - // this logic has been factored out so that if we want to keep going after - // cancel action as well, then we can do this - async function tryOpenNextConversion() { - const nextCompleted = worker.completed.find(c => !c.error); - - if (nextCompleted) { - await vscode.commands.executeCommand("j2k.completed.openDiff", nextCompleted); - - // we are opening a conversion before it's been converted, so it's .java here - // also remains consistent with the tree view - vscode.window.showInformationMessage( - `Automatically opened next Kotlin conversion (${path.basename(nextCompleted.resultUri.fsPath, ".kt")}.java) to be reviewed.` - ); - - // try to also highlight it in the tree view so it's visually appealing - await completedTree - .reveal(nextCompleted, { select: true, focus: false }) - .then(() => {}, () => {}); - } - } - - const queueFile = vscode.commands.registerCommand( - "j2k.queueFile", - async (resource?: vscode.Uri, resources?: vscode.Uri[]) => { - sessionBeginIfRequired(); - const selected = resources?.length - ? resources - : resource - ? [resource] - : []; - - const javaUris = await normaliseSelection(selected); - - javaUris.forEach((uri: vscode.Uri) => { - const queued = queue.toArray().some(item => item.javaUri.fsPath === uri.fsPath); - const running = worker.current && worker.current.javaUri.fsPath === uri.fsPath; - - if (queued || running) { - outputChannel.appendLine(`queueFile: skipped ${path.basename(uri.fsPath)} (already in queue)`); - vscode.window.showInformationMessage(`${path.basename(uri.fsPath)} is already in the queue.`); - - return; - } - - outputChannel.appendLine( - `queueFile: Enqueued ${path.basename(uri.fsPath)}`, - ); - - queue.enqueue(uri); - - vscode.commands.executeCommand("workbench.view.extension.j2k"); - }); - }, - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - "j2k.queue.openProgress", - async (job: Job) => { - let document = await vscode.workspace.openTextDocument(job.progressUri); - - if (document.languageId !== "kotlin") { - document = await vscode.languages.setTextDocumentLanguage( - document, - "kotlin", - ); - } - - await vscode.window.showTextDocument(document, { preview: true }); - }, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - "j2k.completed.openDiff", - async (completedJob: CompletedJob) => { - vcsHandler = await detectVCS(outputChannel); - - const left = await vscode.workspace.openTextDocument( - completedJob.job.javaUri, - ); - - const { dir, name } = path.parse(completedJob.job.javaUri.fsPath); - - const rightUri = vscode.Uri.from({ - scheme: "untitled", - path: path.join(dir, name + ".kt"), - }); - let right = vscode.workspace.textDocuments.find(doc => doc.uri.toString() === rightUri.toString()); - if (!right) { - right = await vscode.workspace.openTextDocument(rightUri); - right = await vscode.languages.setTextDocumentLanguage(right, "kotlin"); - - // provide actual text - const edit = new vscode.WorkspaceEdit(); - edit.replace(rightUri, new vscode.Range(0,0,0,0), completedJob.kotlinText); - await vscode.workspace.applyEdit(edit); - } - - javaUri = completedJob.job.javaUri; - kotlinUri = right.uri; - - await vscode.commands.executeCommand( - "vscode.diff", - left.uri, - right.uri, - "Java to Kotlin Preview", - ); - }, - ), - ); - - const acceptAndReplace = vscode.commands.registerCommand( - "j2k.acceptAndReplaceConversion", - async () => { - // PRE: our diff is open therefore j2k.convertFile has run - assert.ok(javaUri, "javaUri is not set before accepting conversion"); - assert.ok(kotlinUri, "kotlinUri is not set before accepting conversion"); - - const currentJavaUri = javaUri; - const currentKotlinUri = kotlinUri; - - const kotlinDoc = await vscode.workspace.openTextDocument(currentKotlinUri); - const replacementCode = kotlinDoc.getText(); - - // write the replacement code to the same location as the java - - const oldPath = currentJavaUri.fsPath; - const dir = path.dirname(oldPath); - const base = path.basename(oldPath, ".java"); - const newPath = path.join(dir, base + ".kt"); - - const kotlinReplacement = vscode.Uri.file(newPath); - - // backup original java file as .java.j2k - const javaBackupPath = oldPath + ".j2k"; // Foo.java -> Foo.java.j2k - const javaBackupUri = vscode.Uri.file(javaBackupPath); - - // move the java file out of the way - try { - await vscode.workspace.fs.rename(currentJavaUri, javaBackupUri, { overwrite: true }); - } catch (err) { - vscode.window.showErrorMessage( - `Failed to backup original Java file for ${path.basename(oldPath)}: ${String(err)}`, - ); - return; - } - - // write the kotlin file - await vscode.workspace.fs.writeFile( - kotlinReplacement, - Buffer.from(replacementCode, "utf-8"), - ); - - // log the changes made to the file - const originalBase = path.basename(currentJavaUri.fsPath, ".java"); - const logFileName = `${originalBase}_polished.kt`; - logFile(logFileName, replacementCode); - - if (sessionActive) { - if (!sessionWorkspaceFolder) { - sessionWorkspaceFolder = - deriveWorkspaceFolder(kotlinReplacement); - } - - sessionAcceptedFiles.push(kotlinReplacement); - - // sync saved state - persistSessionState(); - } else { - await vcsHandler.stageConversionReplacement(kotlinReplacement); - } - - worker.acceptCompleted(currentJavaUri, kotlinReplacement); - - // tidy up any changed state - await vscode.commands.executeCommand( - "workbench.action.revertAndCloseActiveEditor", - ); - - worker.removeCompleted(currentJavaUri); - - // here we are saving a kotlin file, so output kotlin Uri - vscode.window.showInformationMessage( - `Conversion result for ${path.basename(currentKotlinUri.fsPath)} saved successfully.`, - ); - - return await tryOpenNextConversion(); - }, - ); - - const cancelAndDiscard = vscode.commands.registerCommand( - "j2k.cancelConversion", - async () => { - const currentJavaUri = javaUri; - - // tidy up any changed state - await vscode.commands.executeCommand( - "workbench.action.revertAndCloseActiveEditor", - ); - - if (currentJavaUri) { - worker.removeCompleted(currentJavaUri); - } - - vscode.window.showInformationMessage( - `Conversion cancelled for ${path.basename(currentJavaUri.fsPath)}`, - ); - }, - ); + outputChannel, + sessionManager, + }); - context.subscriptions.push(queueFile, acceptAndReplace, cancelAndDiscard); + registerConfigCommands(context, { + outputChannel, + }); - // only show our buttons when we are actively in the diff editor - vscode.window.onDidChangeActiveTextEditor( - (editor: vscode.TextEditor | undefined) => { - vscode.commands.executeCommand( - "setContext", - "j2k.diffActive", - inDiff(editor), - ); - }, - ); + registerSessionCommands(context, { + session, + worker, + queue, + outputChannel, + sessionManager, + }); // to register bigger, bolder commands from editor/title, // the toolbar automatically detects keybinds to render below // the title. therefore we create aliases which do not have // keybinds - context.subscriptions.push( - vscode.commands.registerCommand("j2k.acceptFromToolbar", () => - vscode.commands.executeCommand("j2k.acceptAndReplaceConversion"), - ), - vscode.commands.registerCommand("j2k.cancelFromToolbar", () => - vscode.commands.executeCommand("j2k.cancelConversion"), - ), - vscode.commands.registerCommand("j2k.commitSessionFromToolbar", () => - vscode.commands.executeCommand("j2k.commitConversionSession"), - ), - vscode.commands.registerCommand("j2k.rejectSessionFromToolbar", () => - vscode.commands.executeCommand("j2k.rejectConversionSession"), - ), - ); - - // api key storage - context.subscriptions.push( - vscode.commands.registerCommand("j2k.setApiKey", async () => { - const key = await vscode.window.showInputBox({ - prompt: "Enter your LLM API key", - password: true, - ignoreFocusOut: true, - }); - - if (key) { - await context.secrets.store("j2k.apiKey", key); - vscode.window.showInformationMessage( - "LLM API key saved to VS Code secure storage.", - ); - - outputChannel.appendLine( - "LLM API key saved to VS Code secure storage.", - ); - } - }), - ); - - // bind the enable kotlin function to a command - context.subscriptions.push( - vscode.commands.registerCommand("j2k.configureKotlin", async () => { - outputChannel.appendLine("Manual trigger: Configuring Kotlin"); - - const systems = await detectBuildSystems(); - - const actionable = systems.filter((s) => s.name !== "none"); - if (actionable.length === 0) { - outputChannel.appendLine("No supported build system detected."); - return; - } - - for (const system of actionable) { - try { - outputChannel.appendLine(`Checking Kotlin setup for ${system.name}…`); - if (await system.needsKotlin()) { - outputChannel.appendLine(`Enabling Kotlin for ${system.name}…`); - await system.enableKotlin(); - outputChannel.appendLine(`Kotlin configured for ${system.name}.`); - } else { - outputChannel.appendLine( - `Kotlin already configured for ${system.name}.`, - ); - } - } catch (err: any) { - outputChannel.appendLine( - `Failed to configure Kotlin for ${system.name}: ${err?.message ?? String(err)}`, - ); - } - } - }), - ); - - context.subscriptions.push( - vscode.commands.registerCommand("j2k.commitConversionSession", async () => { - if (!sessionActive) { - vscode.window.showErrorMessage("No active conversion session."); - return; - } - - const suggestedText = `Convert ${sessionAcceptedFiles.length} files to Kotlin`; - const name = await vscode.window.showInputBox({ - prompt: "Give this coversion session a name (optional)", - placeHolder: suggestedText - }); - - const message = name && name.trim().length > 0 ? name!.trim() : suggestedText; - - try { - const vcsHandler = await detectVCS(outputChannel); - if (typeof vcsHandler.commitAll === "function") { - await vcsHandler.commitAll(sessionAcceptedFiles, message); - } - // nothing to do for non-vcs - - vscode.window.showInformationMessage(`Committed session: ${message}`); - - worker.clearAllViews(queue); - - for (const kotlinUri of sessionAcceptedFiles) { - const kotlinPath = kotlinUri.fsPath; - const dir = path.dirname(kotlinPath); - const base = path.basename(kotlinPath, ".kt"); - - const javaPath = path.join(dir, base + ".java"); - const javaBackupPath = javaPath + ".j2k"; - const javaBackupUri = vscode.Uri.file(javaBackupPath); - - try { - await vscode.workspace.fs.delete(javaBackupUri, { recursive: false, useTrash: false }); - } catch { - // if it doesn't exist, fine - } - } - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to commit session: ${err?.message ?? String(err)}`); - return; - } finally { - // reset session state - sessionActive = false; - sessionAcceptedFiles = []; - vscode.commands.executeCommand("setContext", "j2k.sessionActive", false); - - deleteSessionFile(); - sessionWorkspaceFolder = undefined; - } - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand("j2k.rejectConversionSession", async () => { - if (!sessionActive) { - vscode.window.showErrorMessage("No active conversion session."); - return; - } - - const confirm = await vscode.window.showWarningMessage( - `This will discard Kotlin conversions for ${sessionAcceptedFiles.length} files and restore the original Java files.`, - { modal: true }, - "Discard session", - "Cancel", - ); - - if (confirm !== "Discard session") { - return; - } - - try { - for (const kotlinUri of sessionAcceptedFiles) { - await restoreOriginalFromBackup(kotlinUri); - } - - worker.clearAllViews(queue); - - vscode.window.showInformationMessage("Conversion session discarded and files restored."); - } catch (err: any) { - vscode.window.showErrorMessage( - `Failed to discard session: ${err?.message ?? String(err)}`, - ); - return; - } finally { - sessionActive = false; - sessionAcceptedFiles = []; - vscode.commands.executeCommand("setContext", "j2k.sessionActive", false); - - deleteSessionFile(); - sessionWorkspaceFolder = undefined; - } - }), + registerBindings( + new Map([ + ["j2k.acceptFromToolbar", "j2k.acceptAndReplaceConversion"], + ["j2k.cancelFromToolbar", "j2k.cancelConversion"], + ["j2k.commitSessionFromToolbar", "j2k.commitConversionSession"], + ["j2k.rejectSessionFromToolbar", "j2k.rejectConversionSession"], + ]), ); } diff --git a/extension/src/helpers/batch.ts b/extension/src/helpers/batch.ts new file mode 100644 index 0000000..11ee0c4 --- /dev/null +++ b/extension/src/helpers/batch.ts @@ -0,0 +1,56 @@ +import * as vscode from "vscode"; +import { MemoryContentProvider } from "../batch/memory"; +import { Queue } from "../batch/queue"; +import { Worker } from "../batch/worker"; +import { QueueListProvider } from "../batch/queue-view"; +import { CompletedListProvider } from "../batch/completed-view"; +import { AcceptedListProvider } from "../batch/accepted-view"; + +export type ConversionSession = { + active: boolean; + acceptedFiles: vscode.Uri[]; + workspaceFolder?: vscode.WorkspaceFolder; +}; + +export function createBatchController( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel, + session: ConversionSession, +) { + const queue = new Queue(); + const mem = new MemoryContentProvider(); + const worker = new Worker(context, queue, mem, outputChannel); + worker.start(); + if (session.active && session.acceptedFiles.length > 0) { + worker.restoreAccepted(session.acceptedFiles); + } + + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider("j2k-progress", mem), + ); + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider("j2k-result", mem), + ); + + const acceptedView = new AcceptedListProvider(worker); + const completedView = new CompletedListProvider(worker); + const completedTree = vscode.window.createTreeView("j2k.completed", { + treeDataProvider: completedView, + }); + const queueProvider = new QueueListProvider(queue, worker); + + context.subscriptions.push( + vscode.window.registerTreeDataProvider("j2k.accepted", acceptedView), + completedTree, + vscode.window.registerTreeDataProvider("j2k.queue", queueProvider), + ); + + return { + queue, + mem, + worker, + acceptedView, + completedTree, + queueProvider, + }; +} diff --git a/extension/src/helpers/build-systems.ts b/extension/src/helpers/build-systems.ts new file mode 100644 index 0000000..30b00ef --- /dev/null +++ b/extension/src/helpers/build-systems.ts @@ -0,0 +1,76 @@ +import * as vscode from "vscode"; +import { detectBuildSystems, JVMBuildSystem } from "../build-systems"; + +export async function initialiseBuildSystems( + outputChannel: vscode.OutputChannel, +): Promise { + const buildSystems = await detectBuildSystems(); + outputChannel.appendLine( + `Detected build systems: ${buildSystems.map((s) => s.name).join(", ")}`, + ); + + for (const system of buildSystems) { + if (system.name === "none") { + continue; + } + + if (await system.needsKotlin()) { + outputChannel.appendLine( + `Build system ${system.name} requires Kotlin to be configured.`, + ); + vscode.window + .showInformationMessage( + `This ${system.name} project currently builds only Java. Would you like to add Kotlin support?`, + "Add Kotlin", + "Not now", + ) + .then(async (choice) => { + if (choice !== "Add Kotlin") { + return; + } + + outputChannel.appendLine( + `Configuring Kotlin from prompt for ${system.name}`, + ); + await system.enableKotlin(); + }); + } + } + + return buildSystems; +} + +export async function configureKotlinForBuildSystems( + outputChannel: vscode.OutputChannel, +): Promise { + outputChannel.appendLine("Manual trigger: Configuring Kotlin"); + + const systems = await detectBuildSystems(); + + const actionable = systems.filter((s) => s.name !== "none"); + if (actionable.length === 0) { + outputChannel.appendLine("No supported build system detected."); + return; + } + + for (const system of actionable) { + try { + outputChannel.appendLine(`Checking Kotlin setup for ${system.name}…`); + if (await system.needsKotlin()) { + outputChannel.appendLine(`Enabling Kotlin for ${system.name}…`); + await system.enableKotlin(); + outputChannel.appendLine(`Kotlin configured for ${system.name}.`); + } else { + outputChannel.appendLine( + `Kotlin already configured for ${system.name}.`, + ); + } + } catch (err: any) { + outputChannel.appendLine( + `Failed to configure Kotlin for ${system.name}: ${ + err?.message ?? String(err) + }`, + ); + } + } +} diff --git a/extension/src/helpers/commands/config-commands.ts b/extension/src/helpers/commands/config-commands.ts new file mode 100644 index 0000000..70e01c3 --- /dev/null +++ b/extension/src/helpers/commands/config-commands.ts @@ -0,0 +1,39 @@ +import * as vscode from "vscode"; +import { configureKotlinForBuildSystems } from "../build-systems"; + +export function registerConfigCommands( + context: vscode.ExtensionContext, + deps: { outputChannel: vscode.OutputChannel }, +) { + const { outputChannel } = deps; + + const registerCommand = ( + command: string, + callback: (...args: any[]) => any | Promise, + ): vscode.Disposable => { + const disposable = vscode.commands.registerCommand(command, callback); + context.subscriptions.push(disposable); + return disposable; + }; + + registerCommand("j2k.setApiKey", async () => { + const key = await vscode.window.showInputBox({ + prompt: "Enter your LLM API key", + password: true, + ignoreFocusOut: true, + }); + + if (key) { + await context.secrets.store("j2k.apiKey", key); + vscode.window.showInformationMessage( + "LLM API key saved to VS Code secure storage.", + ); + + outputChannel.appendLine("LLM API key saved to VS Code secure storage."); + } + }); + + registerCommand("j2k.configureKotlin", async () => { + await configureKotlinForBuildSystems(outputChannel); + }); +} diff --git a/extension/src/helpers/commands/conversion-commands.ts b/extension/src/helpers/commands/conversion-commands.ts new file mode 100644 index 0000000..ed1041b --- /dev/null +++ b/extension/src/helpers/commands/conversion-commands.ts @@ -0,0 +1,248 @@ +import * as vscode from "vscode"; +import * as assert from "assert"; +import * as path from "path"; +import { detectVCS, VCSFileRenamer } from "../../vcs"; +import { CompletedJob, Worker } from "../../batch/worker"; +import { Queue } from "../../batch/queue"; +import { AcceptedItem } from "../../batch/accepted-view"; +import { ConversionSession } from "../batch"; +import { logFile } from "../logging"; +import { deriveWorkspaceFolder } from "../fs"; + +type SessionManager = { + persist(): void; +}; + +export function registerConversionCommands( + context: vscode.ExtensionContext, + deps: { + session: ConversionSession; + worker: Worker; + queue: Queue; + completedTree: vscode.TreeView; + outputChannel: vscode.OutputChannel; + sessionManager: SessionManager; + }, +) { + const { + session, + worker, + queue, + completedTree, + outputChannel, + sessionManager, + } = deps; + + let javaUri: vscode.Uri | undefined; + let kotlinUri: vscode.Uri | undefined; + let vcsHandler: VCSFileRenamer; + + const registerCommand = ( + command: string, + callback: (...args: any[]) => any | Promise, + ): vscode.Disposable => { + const disposable = vscode.commands.registerCommand(command, callback); + context.subscriptions.push(disposable); + return disposable; + }; + + function inDiff(editor: vscode.TextEditor | undefined): boolean { + if (!editor) { + return false; + } + + const uri = editor.document.uri.toString(); + + const onRight = + typeof kotlinUri !== "undefined" && uri === kotlinUri.toString(); + const onLeft = typeof javaUri !== "undefined" && uri === javaUri.toString(); + + return onLeft || onRight; + } + + // this logic has been factored out so that if we want to keep going after + // cancel action as well, then we can do this + async function tryOpenNextConversion() { + const nextCompleted = worker.completed.find((c) => !c.error); + + if (nextCompleted) { + await vscode.commands.executeCommand( + "j2k.completed.openDiff", + nextCompleted, + ); + + // we are opening a conversion before it's been converted, so it's .java here + // also remains consistent with the tree view + vscode.window.showInformationMessage( + `Automatically opened next Kotlin conversion (${path.basename(nextCompleted.resultUri.fsPath, ".kt")}.java) to be reviewed.`, + ); + + // try to also highlight it in the tree view so it's visually appealing + await completedTree + .reveal(nextCompleted, { select: true, focus: false }) + .then( + () => {}, + () => {}, + ); + } + } + + registerCommand( + "j2k.completed.openDiff", + async (completedJob: CompletedJob) => { + vcsHandler = await detectVCS(outputChannel); + + const left = await vscode.workspace.openTextDocument( + completedJob.job.javaUri, + ); + + const { dir, name } = path.parse(completedJob.job.javaUri.fsPath); + + const rightUri = vscode.Uri.from({ + scheme: "untitled", + path: path.join(dir, name + ".kt"), + }); + + let right = vscode.workspace.textDocuments.find( + (doc) => doc.uri.toString() === rightUri.toString(), + ); + + if (!right) { + right = await vscode.workspace.openTextDocument(rightUri); + right = await vscode.languages.setTextDocumentLanguage(right, "kotlin"); + + // provide actual text + const edit = new vscode.WorkspaceEdit(); + edit.replace( + rightUri, + new vscode.Range(0, 0, 0, 0), + completedJob.kotlinText, + ); + await vscode.workspace.applyEdit(edit); + } + + javaUri = completedJob.job.javaUri; + kotlinUri = right.uri; + + await vscode.commands.executeCommand( + "vscode.diff", + left.uri, + right.uri, + "Java to Kotlin Preview", + ); + }, + ); + + registerCommand("j2k.acceptAndReplaceConversion", async () => { + // PRE: our diff is open therefore j2k.convertFile has run + assert.ok(javaUri, "javaUri is not set before accepting conversion"); + assert.ok(kotlinUri, "kotlinUri is not set before accepting conversion"); + + const currentJavaUri = javaUri!; + const currentKotlinUri = kotlinUri!; + + const kotlinDoc = await vscode.workspace.openTextDocument(currentKotlinUri); + const replacementCode = kotlinDoc.getText(); + + // write the replacement code to the same location as the java + + const oldPath = currentJavaUri.fsPath; + const dir = path.dirname(oldPath); + const base = path.basename(oldPath, ".java"); + const newPath = path.join(dir, base + ".kt"); + + const kotlinReplacement = vscode.Uri.file(newPath); + + // backup original java file as .java.j2k + const javaBackupPath = oldPath + ".j2k"; + const javaBackupUri = vscode.Uri.file(javaBackupPath); + + // move the java file out of the way + try { + await vscode.workspace.fs.rename(currentJavaUri, javaBackupUri, { + overwrite: true, + }); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to backup original Java file for ${path.basename(oldPath)}: ${String( + err, + )}`, + ); + return; + } + + // write the kotlin file + await vscode.workspace.fs.writeFile( + kotlinReplacement, + Buffer.from(replacementCode, "utf-8"), + ); + + // log the changes made to the file + const originalBase = path.basename(currentJavaUri.fsPath, ".java"); + const logFileName = `${originalBase}_polished.kt`; + logFile(logFileName, replacementCode); + + if (session.active) { + if (!session.workspaceFolder) { + session.workspaceFolder = deriveWorkspaceFolder(kotlinReplacement); + } + + session.acceptedFiles.push(kotlinReplacement); + + // sync saved state + sessionManager.persist(); + } else { + await vcsHandler.stageConversionReplacement(kotlinReplacement); + } + + worker.acceptCompleted(currentJavaUri, kotlinReplacement); + + // tidy up any changed state + await vscode.commands.executeCommand( + "workbench.action.revertAndCloseActiveEditor", + ); + + worker.removeCompleted(currentJavaUri); + + // here we are saving a kotlin file, so output kotlin Uri + vscode.window.showInformationMessage( + `Conversion result for ${path.basename( + currentKotlinUri.fsPath, + )} saved successfully.`, + ); + + return await tryOpenNextConversion(); + }); + + registerCommand("j2k.cancelConversion", async () => { + const currentJavaUri = javaUri; + + // tidy up any changed state + await vscode.commands.executeCommand( + "workbench.action.revertAndCloseActiveEditor", + ); + + if (currentJavaUri) { + worker.removeCompleted(currentJavaUri); + } + + if (currentJavaUri) { + vscode.window.showInformationMessage( + `Conversion cancelled for ${path.basename(currentJavaUri.fsPath)}`, + ); + } + }); + + // only show our buttons when we are actively in the diff editor + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor( + (editor: vscode.TextEditor | undefined) => { + vscode.commands.executeCommand( + "setContext", + "j2k.diffActive", + inDiff(editor), + ); + }, + ), + ); +} diff --git a/extension/src/helpers/commands/queue-commands.ts b/extension/src/helpers/commands/queue-commands.ts new file mode 100644 index 0000000..7de0d15 --- /dev/null +++ b/extension/src/helpers/commands/queue-commands.ts @@ -0,0 +1,85 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import { Queue, Job } from "../../batch/queue"; +import { Worker } from "../../batch/worker"; +import { normaliseSelection } from "../fs"; + +type SessionManager = { + beginIfRequired(): void; +}; + +export function registerQueueCommands( + context: vscode.ExtensionContext, + deps: { + queue: Queue; + worker: Worker; + outputChannel: vscode.OutputChannel; + sessionManager: SessionManager; + }, +) { + const { queue, worker, outputChannel, sessionManager } = deps; + + const registerCommand = ( + command: string, + callback: (...args: any[]) => any | Promise, + ): vscode.Disposable => { + const disposable = vscode.commands.registerCommand(command, callback); + context.subscriptions.push(disposable); + return disposable; + }; + + registerCommand( + "j2k.queueFile", + async (resource?: vscode.Uri, resources?: vscode.Uri[]) => { + sessionManager.beginIfRequired(); + + const selected = resources?.length + ? resources + : resource + ? [resource] + : []; + + const javaUris = await normaliseSelection(selected); + + javaUris.forEach((uri: vscode.Uri) => { + const queued = queue + .toArray() + .some((item) => item.javaUri.fsPath === uri.fsPath); + const running = + worker.current && worker.current.javaUri.fsPath === uri.fsPath; + + if (queued || running) { + outputChannel.appendLine( + `queueFile: skipped ${path.basename(uri.fsPath)} (already in queue)`, + ); + vscode.window.showInformationMessage( + `${path.basename(uri.fsPath)} is already in the queue.`, + ); + + return; + } + + outputChannel.appendLine( + `queueFile: Enqueued ${path.basename(uri.fsPath)}`, + ); + + queue.enqueue(uri); + + vscode.commands.executeCommand("workbench.view.extension.j2k"); + }); + }, + ); + + registerCommand("j2k.queue.openProgress", async (job: Job) => { + let document = await vscode.workspace.openTextDocument(job.progressUri); + + if (document.languageId !== "kotlin") { + document = await vscode.languages.setTextDocumentLanguage( + document, + "kotlin", + ); + } + + await vscode.window.showTextDocument(document, { preview: true }); + }); +} diff --git a/extension/src/helpers/commands/session-commands.ts b/extension/src/helpers/commands/session-commands.ts new file mode 100644 index 0000000..7007b36 --- /dev/null +++ b/extension/src/helpers/commands/session-commands.ts @@ -0,0 +1,124 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import { detectVCS } from "../../vcs"; +import { ConversionSession } from "../batch"; +import { restoreOriginalFromBackup } from "../fs"; +import { Queue } from "../../batch/queue"; +import { Worker } from "../../batch/worker"; + +type SessionManager = { + reset(): void; +}; + +export function registerSessionCommands( + context: vscode.ExtensionContext, + deps: { + session: ConversionSession; + worker: Worker; + queue: Queue; + outputChannel: vscode.OutputChannel; + sessionManager: SessionManager; + }, +) { + const { session, worker, queue, outputChannel, sessionManager } = deps; + + const registerCommand = ( + command: string, + callback: (...args: any[]) => any | Promise, + ): vscode.Disposable => { + const disposable = vscode.commands.registerCommand(command, callback); + context.subscriptions.push(disposable); + return disposable; + }; + + registerCommand("j2k.commitConversionSession", async () => { + if (!session.active) { + vscode.window.showErrorMessage("No active conversion session."); + return; + } + + const suggestedText = `Convert ${session.acceptedFiles.length} files to Kotlin`; + const name = await vscode.window.showInputBox({ + prompt: "Give this coversion session a name (optional)", + placeHolder: suggestedText, + }); + + const message = + name && name.trim().length > 0 ? name.trim() : suggestedText; + + try { + const vcsHandler = await detectVCS(outputChannel); + if (typeof vcsHandler.commitAll === "function") { + await vcsHandler.commitAll(session.acceptedFiles, message); + } + // nothing to do for non-vcs + + vscode.window.showInformationMessage(`Committed session: ${message}`); + + worker.clearAllViews(queue); + + for (const kotlinUri of session.acceptedFiles) { + const kotlinPath = kotlinUri.fsPath; + const dir = path.dirname(kotlinPath); + const base = path.basename(kotlinPath, ".kt"); + + const javaPath = path.join(dir, base + ".java"); + const javaBackupPath = javaPath + ".j2k"; + const javaBackupUri = vscode.Uri.file(javaBackupPath); + + try { + await vscode.workspace.fs.delete(javaBackupUri, { + recursive: false, + useTrash: false, + }); + } catch { + // if it doesn't exist, fine + } + } + } catch (err: any) { + vscode.window.showErrorMessage( + `Failed to commit session: ${err?.message ?? String(err)}`, + ); + return; + } finally { + sessionManager.reset(); + } + }); + + registerCommand("j2k.rejectConversionSession", async () => { + if (!session.active) { + vscode.window.showErrorMessage("No active conversion session."); + return; + } + + const confirm = await vscode.window.showWarningMessage( + `This will discard Kotlin conversions for ${session.acceptedFiles.length} files and restore the original Java files.`, + { modal: true }, + "Discard session", + "Cancel", + ); + + if (confirm !== "Discard session") { + return; + } + + try { + for (const kotlinUri of session.acceptedFiles) { + await restoreOriginalFromBackup(kotlinUri); + } + + worker.clearAllViews(queue); + + vscode.window.showInformationMessage( + "Conversion session discarded and files restored.", + ); + } catch (err: any) { + vscode.window.showErrorMessage( + `Failed to discard session: ${err?.message ?? String(err)}`, + ); + return; + } finally { + sessionManager.reset(); + } + }); +} diff --git a/extension/src/helpers/fs.ts b/extension/src/helpers/fs.ts new file mode 100644 index 0000000..6351149 --- /dev/null +++ b/extension/src/helpers/fs.ts @@ -0,0 +1,74 @@ +import * as vscode from "vscode"; +import * as path from "path"; + +export async function normaliseSelection( + input: vscode.Uri[], +): Promise { + const out: vscode.Uri[] = []; + + for (const uri of input) { + const stat = await vscode.workspace.fs.stat(uri); + + if ((stat.type & vscode.FileType.Directory) !== 0) { + const pattern = new vscode.RelativePattern(uri, "**/*.java"); + const found = await vscode.workspace.findFiles(pattern); + out.push(...found); + } else if (/\.java$/i.test(uri.fsPath)) { + out.push(uri); + } + } + + const normaliseFsPath = (p: string) => { + const n = path.normalize(p); + return process.platform === "win32" ? n.toLowerCase() : n; + }; + + return [...new Map(out.map((u) => [normaliseFsPath(u.fsPath), u])).values()]; +} + +export function deriveWorkspaceFolder( + uri: vscode.Uri, +): vscode.WorkspaceFolder | undefined { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (folder) { + return folder; + } + + const all = vscode.workspace.workspaceFolders; + return all && all.length > 0 ? all[0] : undefined; +} + +export async function restoreOriginalFromBackup( + kotlinUri: vscode.Uri, +): Promise { + const kotlinPath = kotlinUri.fsPath; + const dir = path.dirname(kotlinPath); + const base = path.basename(kotlinPath, ".kt"); + + const javaPath = path.join(dir, base + ".java"); + const javaBackupPath = javaPath + ".j2k"; + + const javaUri = vscode.Uri.file(javaPath); + const javaBackupUri = vscode.Uri.file(javaBackupPath); + + // delete the generated kotlin file, if it exists + try { + await vscode.workspace.fs.stat(kotlinUri); + await vscode.workspace.fs.delete(kotlinUri, { + recursive: false, + useTrash: false, + }); + } catch { + // ignore + } + + // restore backup if present + try { + await vscode.workspace.fs.stat(javaBackupUri); + await vscode.workspace.fs.rename(javaBackupUri, javaUri, { + overwrite: true, + }); + } catch { + // ignore + } +} diff --git a/extension/src/helpers/logging.ts b/extension/src/helpers/logging.ts new file mode 100644 index 0000000..2f65833 --- /dev/null +++ b/extension/src/helpers/logging.ts @@ -0,0 +1,26 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs"; + +export function logFile(filename: string, content: string): void { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + throw new Error("Expected a workspace to be open"); + } + + const basePath = workspaceFolders[0].uri.fsPath; + + const logsDir = path.join(basePath, ".j2k-logs"); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + // let's add a timestamp to keep track of what happened when + const timestamp = new Date().toISOString(); + // for ease of later programmatic inspection, put the timestamp first + const header = `// ${timestamp} (logged at)\n\n`; + + fs.writeFileSync(path.join(logsDir, filename), `${header}${content}`, { + encoding: "utf8", + }); +} diff --git a/extension/src/helpers/session.ts b/extension/src/helpers/session.ts new file mode 100644 index 0000000..2a0e46e --- /dev/null +++ b/extension/src/helpers/session.ts @@ -0,0 +1,165 @@ +// src/helpers/session.ts +import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs"; +import { ConversionSession } from "./batch"; + +const SESSION_STORAGE_NAME = ".j2k-session.tmp"; + +export interface SessionManager { + loadFromDisk(): void; + persist(): void; + reset(): void; + beginIfRequired(): void; +} + +export function createSessionManager( + session: ConversionSession, +): SessionManager { + const setSessionContext = (active: boolean) => { + vscode.commands.executeCommand("setContext", "j2k.sessionActive", active); + }; + + function getSessionFilePath(): string | undefined { + if (!session.workspaceFolder) { + return undefined; + } + + return path.join(session.workspaceFolder.uri.fsPath, SESSION_STORAGE_NAME); + } + + function deleteSessionFile() { + const sessionFilePath = getSessionFilePath(); + if (!sessionFilePath) { + return; + } + + try { + if (fs.existsSync(sessionFilePath)) { + fs.unlinkSync(sessionFilePath); + } + } catch { + // no-op + } + } + + function persist() { + const sessionFilePath = getSessionFilePath(); + if (!sessionFilePath) { + return; + } + + if (!session.active) { + // when the session isn't active, the file must not exist + deleteSessionFile(); + return; + } + + const payload = { + accepted: session.acceptedFiles.map((uri) => uri.fsPath), + }; + + try { + fs.writeFileSync( + sessionFilePath, + JSON.stringify(payload, null, 2), + "utf8", + ); + } catch { + // no-op + } + } + + function loadFromDisk() { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + setSessionContext(false); + return; + } + + session.active = false; + session.acceptedFiles = []; + session.workspaceFolder = undefined; + + for (const folder of folders) { + const sessionFilePath = path.join( + folder.uri.fsPath, + SESSION_STORAGE_NAME, + ); + if (!fs.existsSync(sessionFilePath)) { + continue; + } + + try { + const content = fs.readFileSync(sessionFilePath, "utf8"); + const parsed = JSON.parse(content) as { + accepted?: string[]; + }; + + if (!parsed || !Array.isArray(parsed.accepted)) { + fs.unlinkSync(sessionFilePath); + continue; + } + + const uris: vscode.Uri[] = []; + for (const p of parsed.accepted) { + if (typeof p !== "string") { + continue; + } + if (!fs.existsSync(p)) { + continue; + } + uris.push(vscode.Uri.file(p)); + } + + if (uris.length === 0) { + // nothing to resume - remove the file + fs.unlinkSync(sessionFilePath); + continue; + } + + session.active = true; + session.acceptedFiles = uris; + session.workspaceFolder = folder; + + break; + } catch { + // corrupt or unreadable, best effort clean up + try { + fs.unlinkSync(sessionFilePath); + } catch { + // ignore + } + } + } + + setSessionContext(session.active); + } + + function reset() { + session.active = false; + session.acceptedFiles = []; + session.workspaceFolder = undefined; + setSessionContext(false); + deleteSessionFile(); + } + + function beginIfRequired() { + if (session.active) { + return; + } + + session.active = true; + session.acceptedFiles = []; + session.workspaceFolder = undefined; + setSessionContext(true); + persist(); + } + + return { + loadFromDisk, + persist, + reset, + beginIfRequired, + }; +} diff --git a/extension/src/vcs/git.ts b/extension/src/vcs/git.ts index fa889cd..7d45324 100644 --- a/extension/src/vcs/git.ts +++ b/extension/src/vcs/git.ts @@ -70,7 +70,7 @@ export class GitFileRenamer implements VCSFileRenamer { this.channel.appendLine(`GitFileRenamer: Committed the Kotlin replacement`); } - + async stageWithoutCommit(kotlinUri: vscode.Uri): Promise { const repo: Repository = this.api.getRepository(kotlinUri)!; await repo.add([kotlinUri.fsPath]); @@ -79,15 +79,15 @@ export class GitFileRenamer implements VCSFileRenamer { `GitFileRenamer: Staged ${path.basename(kotlinUri.fsPath)} (no commit made)`, ); } - + async commitAll(uris: vscode.Uri[], message: string): Promise { if (uris.length === 0) { return; } - + const repo: Repository = this.api.getRepository(uris[0])!; - await repo.add(uris.map(u => u.fsPath)); + await repo.add(uris.map((u) => u.fsPath)); await repo.commit(message || `Convert ${uris.length} files to Kotlin`); this.channel.appendLine(`GitFileRenamer: Committed ${uris.length} files`); } diff --git a/extension/src/vcs/index.ts b/extension/src/vcs/index.ts index e71d5b6..07f2257 100644 --- a/extension/src/vcs/index.ts +++ b/extension/src/vcs/index.ts @@ -12,12 +12,12 @@ export interface VCSFileRenamer { renameAndCommit(oldUri: Uri, newUri: Uri): Promise; stageConversionReplacement(kotlinUri: Uri): Promise; - + /* Stage only. */ stageWithoutCommit?(kotlinUri: Uri): Promise; - + /* Single commit for whole session. */ - commitAll(uris: Uri[], message: string): Promise + commitAll(uris: Uri[], message: string): Promise; } export async function detectVCS( diff --git a/extension/src/vcs/standard.ts b/extension/src/vcs/standard.ts index a467751..0f3c037 100644 --- a/extension/src/vcs/standard.ts +++ b/extension/src/vcs/standard.ts @@ -27,12 +27,12 @@ export class StandardFileRenamer implements VCSFileRenamer { `StandardFileRenamer: No-op on staging the conversion replacement`, ); } - + async stageWithoutCommit(kotlinUri: vscode.Uri): Promise { // no op this.channel.appendLine(`StandardFileRenamer: No-op stageWithoutCommit`); } - + async commitAll(uris: vscode.Uri[], message: string): Promise { // no op this.channel.appendLine(`StandardFileRenamer: No-op commitAll`);