-
-
Notifications
You must be signed in to change notification settings - Fork 67
feat: add session-wise diff support using codediff.nvim #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
40977cb
a1aa764
b303625
2befc29
037d991
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,275 @@ | ||
| ---Session diff functionality for opencode.nvim | ||
| ---Uses codediff.nvim to display diffs between session snapshots. | ||
| local M = {} | ||
| local data_dir = vim.fn.expand(require("opencode.config").opts.diff_data_dir) | ||
|
|
||
| ---@class opencode.diff.Snapshots | ||
| ---@field first string|nil First snapshot hash (step-start) | ||
| ---@field last string|nil Last snapshot hash (step-finish) | ||
| ---@field project_id string|nil Project ID for the session | ||
|
|
||
| ---Read JSON file and parse it | ||
| ---@param path string | ||
| ---@return table|nil | ||
| local function read_json(path) | ||
| local file = io.open(path, "r") | ||
| if not file then | ||
| return nil | ||
| end | ||
| local content = file:read("*a") | ||
| file:close() | ||
| local ok, data = pcall(vim.fn.json_decode, content) | ||
| if ok then | ||
| return data | ||
| end | ||
| return nil | ||
| end | ||
|
|
||
| ---Get list of message IDs for a session (sorted) | ||
| ---@param session_id string | ||
| ---@return string[] | ||
| local function get_message_ids(session_id) | ||
| local msg_dir = data_dir .. "/storage/message/" .. session_id | ||
| local handle = vim.uv.fs_scandir(msg_dir) | ||
| if not handle then | ||
| return {} | ||
| end | ||
|
|
||
| local messages = {} | ||
| while true do | ||
| local name, ftype = vim.uv.fs_scandir_next(handle) | ||
| if not name then | ||
| break | ||
| end | ||
| if ftype == "file" and name:match("%.json$") then | ||
| local msg_id = name:gsub("%.json$", "") | ||
| table.insert(messages, msg_id) | ||
| end | ||
| end | ||
|
|
||
| table.sort(messages) | ||
| return messages | ||
| end | ||
|
|
||
| ---Get snapshots from part files for a message | ||
| ---@param msg_id string | ||
| ---@return { step_starts: string[], step_finishes: string[] } | ||
| local function get_message_snapshots(msg_id) | ||
| local part_dir = data_dir .. "/storage/part/" .. msg_id | ||
| local handle = vim.uv.fs_scandir(part_dir) | ||
| if not handle then | ||
| return { step_starts = {}, step_finishes = {} } | ||
| end | ||
|
|
||
| local step_starts = {} | ||
| local step_finishes = {} | ||
|
|
||
| -- Collect all part files | ||
| local parts = {} | ||
| while true do | ||
| local name, ftype = vim.uv.fs_scandir_next(handle) | ||
| if not name then | ||
| break | ||
| end | ||
| if ftype == "file" and name:match("%.json$") then | ||
| table.insert(parts, name) | ||
| end | ||
| end | ||
| table.sort(parts) | ||
|
|
||
| for _, part_name in ipairs(parts) do | ||
| local part_file = part_dir .. "/" .. part_name | ||
| local data = read_json(part_file) | ||
| if data and data.snapshot then | ||
| if data.type == "step-start" then | ||
| table.insert(step_starts, data.snapshot) | ||
| elseif data.type == "step-finish" then | ||
| table.insert(step_finishes, data.snapshot) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| return { step_starts = step_starts, step_finishes = step_finishes } | ||
| end | ||
|
|
||
| ---Find snapshots for session-wide diff | ||
| ---@param session_id string | ||
| ---@param project_id string | ||
| ---@return opencode.diff.Snapshots | ||
| local function find_session_snapshots(session_id, project_id) | ||
| local messages = get_message_ids(session_id) | ||
| local first = nil | ||
| local last = nil | ||
|
|
||
| for _, msg_id in ipairs(messages) do | ||
| local snaps = get_message_snapshots(msg_id) | ||
|
|
||
| -- First step-start across all messages | ||
| if not first and #snaps.step_starts > 0 then | ||
| first = snaps.step_starts[1] | ||
| end | ||
|
|
||
| -- Last step-finish across all messages | ||
| if #snaps.step_finishes > 0 then | ||
| last = snaps.step_finishes[#snaps.step_finishes] | ||
| end | ||
| end | ||
|
|
||
| return { | ||
| first = first, | ||
| last = last, | ||
| project_id = project_id, | ||
| } | ||
| end | ||
|
|
||
| ---Export a git tree to a temporary directory | ||
| ---@param snapshot_git string Path to the snapshot git directory | ||
| ---@param tree_hash string Git tree hash | ||
| ---@param callback fun(err: string|nil, temp_dir: string|nil) | ||
| local function export_tree(snapshot_git, tree_hash, callback) | ||
| -- Create temp directory | ||
| local temp_dir = vim.fn.tempname() | ||
| vim.fn.mkdir(temp_dir, "p") | ||
|
|
||
| -- Verify the object exists | ||
| vim.system({ "git", "--git-dir=" .. snapshot_git, "cat-file", "-t", tree_hash }, { text = true }, function(result) | ||
| if result.code ~= 0 then | ||
| vim.schedule(function() | ||
| callback("Snapshot object no longer exists (may have been garbage collected): " .. tree_hash, nil) | ||
| end) | ||
| return | ||
| end | ||
|
|
||
| -- Export the tree using git archive | ||
| vim.system({ "git", "--git-dir=" .. snapshot_git, "archive", tree_hash }, { text = false }, function(archive_result) | ||
| if archive_result.code ~= 0 then | ||
| vim.schedule(function() | ||
| callback("Failed to archive tree: " .. (archive_result.stderr or ""), nil) | ||
| end) | ||
| return | ||
| end | ||
|
|
||
| -- Extract the archive | ||
| vim.system( | ||
| { "tar", "-xf", "-", "-C", temp_dir }, | ||
| { stdin = archive_result.stdout, text = false }, | ||
| function(tar_result) | ||
| vim.schedule(function() | ||
| if tar_result.code ~= 0 then | ||
| callback("Failed to extract archive: " .. (tar_result.stderr or ""), nil) | ||
| else | ||
| callback(nil, temp_dir) | ||
| end | ||
| end) | ||
| end | ||
| ) | ||
| end) | ||
| end) | ||
| end | ||
|
Comment on lines
+129
to
+168
|
||
|
|
||
| ---Show diff between two snapshots using codediff.nvim | ||
| ---@param snapshots opencode.diff.Snapshots | ||
| local function show_diff(snapshots) | ||
| if not snapshots.first or not snapshots.last then | ||
| vim.notify("No snapshots found for session diff", vim.log.levels.WARN, { title = "opencode" }) | ||
| return | ||
| end | ||
|
|
||
| if snapshots.first == snapshots.last then | ||
| vim.notify("No changes in session (same snapshot)", vim.log.levels.INFO, { title = "opencode" }) | ||
| return | ||
| end | ||
|
|
||
| if not snapshots.project_id then | ||
| vim.notify("Could not determine project ID", vim.log.levels.ERROR, { title = "opencode" }) | ||
| return | ||
| end | ||
|
|
||
| local snapshot_git = data_dir .. "/snapshot/" .. snapshots.project_id | ||
|
|
||
| -- Check if snapshot git directory exists | ||
| if vim.fn.isdirectory(snapshot_git) == 0 then | ||
| vim.notify("Snapshot directory not found: " .. snapshot_git, vim.log.levels.ERROR, { title = "opencode" }) | ||
| return | ||
| end | ||
|
|
||
| -- Export both trees and show diff | ||
| export_tree(snapshot_git, snapshots.first, function(err1, dir1) | ||
| if err1 or not dir1 then | ||
| vim.notify(err1 or "Failed to export first snapshot", vim.log.levels.ERROR, { title = "opencode" }) | ||
| return | ||
| end | ||
|
|
||
| export_tree(snapshot_git, snapshots.last, function(err2, dir2) | ||
| if err2 or not dir2 then | ||
| vim.notify(err2 or "Failed to export second snapshot", vim.log.levels.ERROR, { title = "opencode" }) | ||
| return | ||
| end | ||
|
|
||
| -- Show diff using codediff.nvim | ||
| vim.notify( | ||
| "Session diff: " .. snapshots.first:sub(1, 7) .. " → " .. snapshots.last:sub(1, 7), | ||
| vim.log.levels.INFO, | ||
| { title = "opencode" } | ||
| ) | ||
|
|
||
| -- Use CodeDiff command to compare directories | ||
| local ok, cmd_err = pcall(function() | ||
| vim.cmd("CodeDiff " .. vim.fn.fnameescape(dir1) .. " " .. vim.fn.fnameescape(dir2)) | ||
| end) | ||
| if not ok then | ||
| vim.notify("Failed to run CodeDiff: " .. tostring(cmd_err), vim.log.levels.ERROR, { title = "opencode" }) | ||
| end | ||
| end) | ||
| end) | ||
| end | ||
|
|
||
| -- Show session-wide diff (first step-start → last step-finish across all messages) | ||
| function M.session_diff() | ||
| local has_codediff = pcall(require, "codediff") | ||
| if not has_codediff then | ||
| vim.notify( | ||
| "codediff.nvim is required for session diff. Install it from https://github.com/esmuellert/codediff.nvim", | ||
| vim.log.levels.ERROR, | ||
| { title = "opencode" } | ||
| ) | ||
| return | ||
| end | ||
|
|
||
| require("opencode.cli.server") | ||
| .get_port() | ||
| :next(function(port) | ||
| require("opencode.cli.client").get_sessions(port, function(sessions) | ||
| if not sessions or #sessions == 0 then | ||
| vim.notify("No sessions found", vim.log.levels.WARN, { title = "opencode" }) | ||
| return | ||
| end | ||
|
|
||
| -- Sort by updated time, most recent first | ||
| table.sort(sessions, function(a, b) | ||
| local a_time = (a.time and a.time.updated) or 0 | ||
| local b_time = (b.time and b.time.updated) or 0 | ||
| return a_time > b_time | ||
| end) | ||
|
|
||
| vim.ui.select(sessions, { | ||
| prompt = "Select session to diff:", | ||
| format_item = function(session) | ||
| return string.format("%-20s", session.title or session.slug or session.id) | ||
| end, | ||
| }, function(choice) | ||
| if not choice then | ||
| return | ||
| end | ||
|
|
||
| local snapshots = find_session_snapshots(choice.id, choice.projectID) | ||
| show_diff(snapshots) | ||
| end) | ||
| end) | ||
| end) | ||
| :catch(function(err) | ||
| vim.notify(err, vim.log.levels.ERROR, { title = "opencode" }) | ||
| end) | ||
| end | ||
|
|
||
| return M | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the git cat-file or git archive commands fail after creating the temp directory, that directory is not cleaned up. The temp_dir created at line 133-134 should be deleted in the error paths at lines 140 and 149 to prevent temporary directory leaks.