Skip to content

Commit d24c4f3

Browse files
authored
Merge pull request #86 from dlants/dlants-context-following
rearchitect context following
2 parents 325c0ee + cbd4a57 commit d24c4f3

26 files changed

+2115
-1198
lines changed

context.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,5 @@ when doing integration-level testing, like user flows, use the `withDriver` help
169169
# Notes
170170

171171
To avoid complexity, keep variable names on the lua side camelCase, to match the variables defined in typescript.
172+
173+
We only want to use a single bottom value, so use undefined whenever you can and avoid null. When external libraries use null, only use null at the boundary, and convert to undefined as early as possible, so the internals of the plugin only use undefined.

lua/magenta/init.lua

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ local visual_commands = {
6868
}
6969

7070
M.bridge = function(channelId)
71+
-- Store the channel ID for later use by other functions
72+
M.channel_id = channelId
73+
7174
vim.api.nvim_create_user_command(
7275
"Magenta",
7376
function(opts)
@@ -101,6 +104,95 @@ M.bridge = function(channelId)
101104
}
102105
)
103106

107+
-- Helper function to check if a buffer is a real file
108+
local function is_real_file(file_path, bufnr, strict_mode)
109+
-- Basic path check
110+
if not file_path or file_path == "" then
111+
return false
112+
end
113+
114+
-- Buffer validity check
115+
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
116+
return false
117+
end
118+
119+
-- Check buffer type (must be empty for real files)
120+
if vim.bo[bufnr].buftype ~= "" then
121+
return false
122+
end
123+
124+
-- For strict mode, perform additional checks
125+
if strict_mode then
126+
-- Check if buffer is listed
127+
if not vim.bo[bufnr].buflisted then
128+
return false
129+
end
130+
131+
-- Check if file exists or has a filetype
132+
if vim.fn.filereadable(file_path) ~= 1 and vim.fn.getftype(file_path) == "" then
133+
return false
134+
end
135+
else
136+
-- For non-strict mode, just verify the path format
137+
if not (file_path:match("^%a:[\\/]") or file_path:match("^/")) then
138+
return false
139+
end
140+
end
141+
142+
return true
143+
end
144+
145+
-- Setup buffer event tracking
146+
vim.api.nvim_create_autocmd(
147+
"BufWritePost",
148+
{
149+
pattern = "*",
150+
callback = function()
151+
local file_path = vim.fn.expand("<afile>:p")
152+
local bufnrString = vim.fn.expand("<abuf>")
153+
local bufnr = tonumber(bufnrString)
154+
155+
-- For write events, we need to verify readability
156+
if is_real_file(file_path, bufnr, true) and vim.fn.filereadable(file_path) == 1 then
157+
vim.rpcnotify(channelId, "magentaBufferTracker", "write", file_path, bufnr)
158+
end
159+
end
160+
}
161+
)
162+
163+
vim.api.nvim_create_autocmd(
164+
"BufReadPost",
165+
{
166+
pattern = "*",
167+
callback = function()
168+
local file_path = vim.fn.expand("<afile>:p")
169+
local bufnrString = vim.fn.expand("<abuf>")
170+
local bufnr = tonumber(bufnrString)
171+
172+
if is_real_file(file_path, bufnr, true) then
173+
vim.rpcnotify(channelId, "magentaBufferTracker", "read", file_path, bufnr)
174+
end
175+
end
176+
}
177+
)
178+
179+
vim.api.nvim_create_autocmd(
180+
"BufDelete",
181+
{
182+
pattern = "*",
183+
callback = function()
184+
local file_path = vim.fn.expand("<afile>:p")
185+
local bufnrString = vim.fn.expand("<abuf>")
186+
local bufnr = tonumber(bufnrString)
187+
188+
-- For delete events, we use less strict checks
189+
if is_real_file(file_path, bufnr, false) then
190+
vim.rpcnotify(channelId, "magentaBufferTracker", "close", file_path, bufnr)
191+
end
192+
end
193+
}
194+
)
195+
104196
M.listenToBufKey = function(bufnr, vimKey)
105197
vim.keymap.set(
106198
"n",

node/buffer-tracker.spec.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { describe, expect, it } from "vitest";
2+
import { withDriver, TMP_DIR } from "./test/preamble";
3+
import fs from "node:fs";
4+
import path from "node:path";
5+
import { promisify } from "util";
6+
import { getCurrentBuffer } from "./nvim/nvim";
7+
import type { AbsFilePath } from "./utils/files";
8+
import type { Line } from "./nvim/buffer";
9+
10+
const writeFile = promisify(fs.writeFile);
11+
12+
describe("node/buffer-tracker.spec.ts", () => {
13+
it("should track buffer as not modified after initial read", async () => {
14+
await withDriver({}, async (driver) => {
15+
// Create a temporary file for testing
16+
const filePath = path.join(TMP_DIR, "test-file.txt") as AbsFilePath;
17+
await writeFile(filePath, "initial content");
18+
19+
await driver.editFile(filePath);
20+
const buffer = await getCurrentBuffer(driver.nvim);
21+
22+
// Check buffer name matches the file path
23+
const bufferName = await buffer.getName();
24+
expect(bufferName).toContain(filePath);
25+
26+
// Get tracker and verify it's tracking the buffer
27+
const bufferTracker = driver.magenta.bufferTracker;
28+
await bufferTracker.trackBufferSync(filePath, buffer.id);
29+
30+
// Check buffer is not modified
31+
const isModified = await bufferTracker.isBufferModifiedSinceSync(
32+
filePath,
33+
buffer.id,
34+
);
35+
expect(isModified).toBe(false);
36+
37+
// Verify sync info exists
38+
const syncInfo = bufferTracker.getSyncInfo(filePath);
39+
expect(syncInfo).toBeDefined();
40+
expect(syncInfo?.bufnr).toBe(buffer.id);
41+
});
42+
});
43+
44+
it("should detect buffer as modified after edits without saving", async () => {
45+
await withDriver({}, async (driver) => {
46+
// Create a temporary file for testing
47+
const filePath = path.join(
48+
TMP_DIR,
49+
"test-file-modified.txt",
50+
) as AbsFilePath;
51+
await writeFile(filePath, "initial content");
52+
53+
// Edit the file
54+
await driver.editFile(filePath);
55+
const buffer = await getCurrentBuffer(driver.nvim);
56+
57+
// Check buffer name matches the file path
58+
const bufferName = await buffer.getName();
59+
expect(bufferName).toContain(filePath);
60+
61+
// Get tracker and initial state
62+
const bufferTracker = driver.magenta.bufferTracker;
63+
await bufferTracker.trackBufferSync(filePath, buffer.id);
64+
const initialSyncInfo = bufferTracker.getSyncInfo(filePath);
65+
66+
// Modify the buffer without saving
67+
await buffer.setLines({
68+
start: 0,
69+
end: -1,
70+
lines: ["modified content" as Line],
71+
});
72+
73+
// Check buffer is detected as modified
74+
const isModified = await bufferTracker.isBufferModifiedSinceSync(
75+
filePath,
76+
buffer.id,
77+
);
78+
expect(isModified).toBe(true);
79+
80+
// Verify mtime hasn't changed
81+
const currentSyncInfo = bufferTracker.getSyncInfo(filePath);
82+
expect(currentSyncInfo?.mtime).toBe(initialSyncInfo?.mtime);
83+
});
84+
});
85+
86+
it("should update sync info after writing changes to disk", async () => {
87+
await withDriver({}, async (driver) => {
88+
// Create a temporary file for testing
89+
const filePath = path.join(TMP_DIR, "test-file-write.txt") as AbsFilePath;
90+
await writeFile(filePath, "initial content");
91+
92+
// Edit the file
93+
await driver.editFile(filePath);
94+
const buffer = await getCurrentBuffer(driver.nvim);
95+
96+
// Check buffer name matches the file path
97+
const bufferName = await buffer.getName();
98+
expect(bufferName).toContain(filePath);
99+
100+
// Get tracker and initial state
101+
const bufferTracker = driver.magenta.bufferTracker;
102+
await bufferTracker.trackBufferSync(filePath, buffer.id);
103+
const initialSyncInfo = bufferTracker.getSyncInfo(filePath);
104+
105+
// Modify the buffer
106+
await buffer.setLines({
107+
start: 0,
108+
end: -1,
109+
lines: ["modified content" as Line],
110+
});
111+
112+
// Save the buffer
113+
await driver.command("write");
114+
115+
// Track the sync again
116+
await bufferTracker.trackBufferSync(filePath, buffer.id);
117+
118+
// Check buffer is no longer modified
119+
const isModified = await bufferTracker.isBufferModifiedSinceSync(
120+
filePath,
121+
buffer.id,
122+
);
123+
expect(isModified).toBe(false);
124+
125+
// Verify mtime has changed
126+
const updatedSyncInfo = bufferTracker.getSyncInfo(filePath);
127+
expect(updatedSyncInfo?.mtime).toBeGreaterThan(
128+
initialSyncInfo?.mtime as number,
129+
);
130+
});
131+
});
132+
133+
it("should clear tracking info when requested", async () => {
134+
await withDriver({}, async (driver) => {
135+
// Create a temporary file for testing
136+
const filePath = path.join(TMP_DIR, "test-file-clear.txt") as AbsFilePath;
137+
await writeFile(filePath, "initial content");
138+
139+
// Edit the file
140+
await driver.editFile(filePath);
141+
const buffer = await getCurrentBuffer(driver.nvim);
142+
143+
// Check buffer name matches the file path
144+
const bufferName = await buffer.getName();
145+
expect(bufferName).toContain(filePath);
146+
147+
// Get tracker and initial state
148+
const bufferTracker = driver.magenta.bufferTracker;
149+
await bufferTracker.trackBufferSync(filePath, buffer.id);
150+
151+
// Verify sync info exists
152+
expect(bufferTracker.getSyncInfo(filePath)).toBeDefined();
153+
154+
// Clear tracking info
155+
bufferTracker.clearFileTracking(filePath);
156+
157+
// Verify sync info is gone
158+
expect(bufferTracker.getSyncInfo(filePath)).toBeUndefined();
159+
});
160+
});
161+
});

node/buffer-tracker.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Nvim } from "./nvim/nvim-node";
2+
import type { AbsFilePath } from "./utils/files";
3+
import { NvimBuffer, type BufNr } from "./nvim/buffer";
4+
import fs from "node:fs";
5+
6+
interface BufferSyncInfo {
7+
mtime: number; // File modification time when last synced
8+
changeTick: number; // Buffer changeTick when last synced
9+
bufnr: BufNr; // Buffer number
10+
}
11+
12+
/**
13+
* Global tracker for buffer sync state
14+
* Tracks when buffers were last synced with their files on disk
15+
*/
16+
export class BufferTracker {
17+
private bufferSyncInfo: Record<AbsFilePath, BufferSyncInfo> = {};
18+
19+
constructor(private nvim: Nvim) {}
20+
21+
/**
22+
* Track when a buffer is synced with the file (on open or write)
23+
*/
24+
public async trackBufferSync(
25+
absFilePath: AbsFilePath,
26+
bufnr: BufNr,
27+
): Promise<void> {
28+
try {
29+
const stats = await fs.promises.stat(absFilePath);
30+
const buffer = new NvimBuffer(bufnr, this.nvim);
31+
const changeTick = await buffer.getChangeTick();
32+
33+
this.bufferSyncInfo[absFilePath] = {
34+
mtime: stats.mtime.getTime(),
35+
changeTick,
36+
bufnr,
37+
};
38+
} catch (error) {
39+
this.nvim.logger?.error(
40+
`Error tracking buffer sync for ${absFilePath}:`,
41+
error,
42+
);
43+
}
44+
}
45+
46+
public async isBufferModifiedSinceSync(
47+
absFilePath: AbsFilePath,
48+
bufnr: BufNr,
49+
): Promise<boolean> {
50+
const syncInfo = this.bufferSyncInfo[absFilePath];
51+
if (!syncInfo) {
52+
throw new Error(
53+
`Expected bufnr ${bufnr} to have buffer-tracker info but it did not.`,
54+
);
55+
}
56+
57+
const buffer = new NvimBuffer(bufnr, this.nvim);
58+
const currentChangeTick = await buffer.getChangeTick();
59+
return currentChangeTick !== syncInfo.changeTick;
60+
}
61+
62+
public getSyncInfo(absFilePath: AbsFilePath): BufferSyncInfo | undefined {
63+
return this.bufferSyncInfo[absFilePath] || undefined;
64+
}
65+
66+
public clearFileTracking(absFilePath: AbsFilePath): void {
67+
delete this.bufferSyncInfo[absFilePath];
68+
}
69+
}

0 commit comments

Comments
 (0)