diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 40063dc70..00f90857c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -20,6 +20,8 @@ const ZedEditorContentsSchema = z.object({ contents: z.string().nullable(), }) +const utf8 = new TextEncoder() + type ZedEditorRow = z.infer type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number } @@ -45,8 +47,8 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): .catch(() => undefined) if (text == null) return { type: "unavailable" } - const startOffset = Math.min(row.selection_start, row.selection_end) - const endOffset = Math.max(row.selection_start, row.selection_end) + const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end)) + const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end)) return { type: "selection", @@ -158,7 +160,25 @@ function zedWorkspacePaths(value: string | null) { } export function offsetToPosition(text: string, offset: number) { - return offsetsToSelection(text, offset, offset).start + const stringOffset = utf8ByteOffsetToStringIndex(text, offset) + return offsetsToSelection(text, stringOffset, stringOffset).start +} + +function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) { + if (byteOffset <= 0) return 0 + + let bytes = 0 + for (let index = 0; index < text.length; ) { + const codePoint = text.codePointAt(index) + if (codePoint === undefined) return text.length + + const nextIndex = index + (codePoint > 0xffff ? 2 : 1) + bytes += utf8.encode(text.slice(index, nextIndex)).length + if (bytes >= byteOffset) return nextIndex + index = nextIndex + } + + return text.length } function offsetsToSelection(text: string, startOffset: number, endOffset: number) { diff --git a/packages/opencode/test/cli/tui/editor-context.test.ts b/packages/opencode/test/cli/tui/editor-context.test.ts index 767eeb8ec..4c5491461 100644 --- a/packages/opencode/test/cli/tui/editor-context.test.ts +++ b/packages/opencode/test/cli/tui/editor-context.test.ts @@ -10,12 +10,14 @@ type ZedFixtureOptions = { editor?: boolean selectionStart?: number | null selectionEnd?: number | null + contents?: string } async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) { const dbPath = path.join(dir, "zed.sqlite") const filePath = path.join(dir, "file.ts") - await Bun.write(filePath, "one\ntwo\nthree") + const contents = options.contents ?? "one\ntwo\nthree" + await Bun.write(filePath, contents) const db = new Database(dbPath) db.run("create table workspaces (workspace_id integer, paths text, timestamp text)") @@ -27,7 +29,7 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) { db.run("insert into panes values (1, 1, 1)") db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"]) if (options.editor !== false) { - db.run("insert into editors values (1, 1, ?, ?)", [filePath, "one\ntwo\nthree"]) + db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents]) db.run("insert into editor_selections values (1, 1, ?, ?)", [ options.selectionStart === undefined ? 4 : options.selectionStart, options.selectionEnd === undefined ? 7 : options.selectionEnd, @@ -38,11 +40,23 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) { return { dbPath, filePath } } +function utf8ByteOffset(text: string, offset: number) { + return new TextEncoder().encode(text.slice(0, offset)).length +} + test("offsetToPosition converts Zed offsets to 1-based editor positions", () => { expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 }) expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 }) expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 }) expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 }) + expect(offsetToPosition("Ж\nabc", utf8ByteOffset("Ж\nabc", "Ж\nabc".indexOf("a")))).toEqual({ + line: 2, + character: 1, + }) + expect(offsetToPosition("😀\nabc", utf8ByteOffset("😀\nabc", "😀\nabc".indexOf("a")))).toEqual({ + line: 2, + character: 1, + }) }) test("resolveZedSelection returns active editor selection", async () => { @@ -63,6 +77,102 @@ test("resolveZedSelection returns active editor selection", async () => { }) }) +test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", async () => { + await using tmp = await tmpdir() + const contents = "a\nЖЖЖЖЖЖЖЖЖЖ\nb\nTARGET\nz" + const start = contents.indexOf("TARGET") + const fixture = await writeZedFixture(tmp.path, { + contents, + selectionStart: utf8ByteOffset(contents, start), + selectionEnd: utf8ByteOffset(contents, start + "TARGET".length), + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + text: "TARGET", + filePath: fixture.filePath, + source: "zed", + selection: { + start: { line: 4, character: 1 }, + end: { line: 4, character: 7 }, + }, + }, + }) +}) + +test("resolveZedSelection handles non-ASCII text inside the selected range", async () => { + await using tmp = await tmpdir() + const contents = "a\npre\nвыбор\nz" + const start = contents.indexOf("выбор") + const fixture = await writeZedFixture(tmp.path, { + contents, + selectionStart: utf8ByteOffset(contents, start), + selectionEnd: utf8ByteOffset(contents, start + "выбор".length), + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + text: "выбор", + filePath: fixture.filePath, + source: "zed", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 6 }, + }, + }, + }) +}) + +test("resolveZedSelection handles emoji before the selected range", async () => { + await using tmp = await tmpdir() + const contents = "😀\nTARGET\nz" + const start = contents.indexOf("TARGET") + const fixture = await writeZedFixture(tmp.path, { + contents, + selectionStart: utf8ByteOffset(contents, start), + selectionEnd: utf8ByteOffset(contents, start + "TARGET".length), + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + text: "TARGET", + filePath: fixture.filePath, + source: "zed", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 7 }, + }, + }, + }) +}) + +test("resolveZedSelection handles reversed Zed byte offsets", async () => { + await using tmp = await tmpdir() + const contents = "a\nЖЖЖ\nTARGET\nz" + const start = contents.indexOf("TARGET") + const fixture = await writeZedFixture(tmp.path, { + contents, + selectionStart: utf8ByteOffset(contents, start + "TARGET".length), + selectionEnd: utf8ByteOffset(contents, start), + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + text: "TARGET", + filePath: fixture.filePath, + source: "zed", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 7 }, + }, + }, + }) +}) + test("resolveZedSelection returns empty when no workspace matches", async () => { await using tmp = await tmpdir() const fixture = await writeZedFixture(tmp.path, {