fix(tui): handle Zed selection byte offsets (#24825)
This commit is contained in:
@@ -20,6 +20,8 @@ const ZedEditorContentsSchema = z.object({
|
|||||||
contents: z.string().nullable(),
|
contents: z.string().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const utf8 = new TextEncoder()
|
||||||
|
|
||||||
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
|
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
|
||||||
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
|
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
|
||||||
|
|
||||||
@@ -45,8 +47,8 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (text == null) return { type: "unavailable" }
|
if (text == null) return { type: "unavailable" }
|
||||||
|
|
||||||
const startOffset = Math.min(row.selection_start, row.selection_end)
|
const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end))
|
||||||
const endOffset = Math.max(row.selection_start, row.selection_end)
|
const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "selection",
|
type: "selection",
|
||||||
@@ -158,7 +160,25 @@ function zedWorkspacePaths(value: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function offsetToPosition(text: string, offset: number) {
|
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) {
|
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ type ZedFixtureOptions = {
|
|||||||
editor?: boolean
|
editor?: boolean
|
||||||
selectionStart?: number | null
|
selectionStart?: number | null
|
||||||
selectionEnd?: number | null
|
selectionEnd?: number | null
|
||||||
|
contents?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
|
async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
|
||||||
const dbPath = path.join(dir, "zed.sqlite")
|
const dbPath = path.join(dir, "zed.sqlite")
|
||||||
const filePath = path.join(dir, "file.ts")
|
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)
|
const db = new Database(dbPath)
|
||||||
db.run("create table workspaces (workspace_id integer, paths text, timestamp text)")
|
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 panes values (1, 1, 1)")
|
||||||
db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"])
|
db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"])
|
||||||
if (options.editor !== false) {
|
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, ?, ?)", [
|
db.run("insert into editor_selections values (1, 1, ?, ?)", [
|
||||||
options.selectionStart === undefined ? 4 : options.selectionStart,
|
options.selectionStart === undefined ? 4 : options.selectionStart,
|
||||||
options.selectionEnd === undefined ? 7 : options.selectionEnd,
|
options.selectionEnd === undefined ? 7 : options.selectionEnd,
|
||||||
@@ -38,11 +40,23 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
|
|||||||
return { dbPath, filePath }
|
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", () => {
|
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", 0)).toEqual({ line: 1, character: 1 })
|
||||||
expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, 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", 6)).toEqual({ line: 2, character: 3 })
|
||||||
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
|
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 () => {
|
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 () => {
|
test("resolveZedSelection returns empty when no workspace matches", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const fixture = await writeZedFixture(tmp.path, {
|
const fixture = await writeZedFixture(tmp.path, {
|
||||||
|
|||||||
Reference in New Issue
Block a user