Skip to content

Commit 3a3ebcc

Browse files
committed
chore(image-paste): code cleanup and tests
1 parent 0994693 commit 3a3ebcc

File tree

7 files changed

+374
-121
lines changed

7 files changed

+374
-121
lines changed

lua/opencode/context.lua

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function M.is_context_enabled(context_key)
6666
end
6767
end
6868

69+
---@return OpencodeDiagnostic[]|nil
6970
function M.get_diagnostics(buf)
7071
if not M.is_context_enabled('diagnostics') then
7172
return nil
@@ -146,11 +147,18 @@ function M.add_file(file)
146147
return
147148
end
148149

150+
if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then
151+
vim.notify('File not added to context. Must be inside current working directory.')
152+
return
153+
end
154+
149155
file = vim.fn.fnamemodify(file, ':p')
150156

151157
if not vim.tbl_contains(M.context.mentioned_files, file) then
152158
table.insert(M.context.mentioned_files, file)
153159
end
160+
161+
state.context_updated_at = vim.uv.now()
154162
end
155163

156164
function M.remove_file(file)
@@ -358,7 +366,7 @@ local function format_selection_part(selection)
358366
}
359367
end
360368

361-
---@param diagnostics vim.Diagnostic[]
369+
---@param diagnostics OpencodeDiagnostic[]
362370
local function format_diagnostics_part(diagnostics)
363371
local diag_list = {}
364372
for _, diag in ipairs(diagnostics) do

lua/opencode/image_handler.lua

Lines changed: 105 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,128 +1,127 @@
1+
--- Image pasting functionality from clipboard
2+
--- @see https://github.com/sst/opencode/blob/45180104fe84e2d0b9d29be0f9f8a5e52d18e102/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
13
local context = require('opencode.context')
24
local state = require('opencode.state')
35

46
local M = {}
57

6-
--- Check if a file was successfully created and has content
7-
--- @param path string File path to check
8-
--- @return boolean success True if file exists and has content
8+
--- Check if a file exists and has content
9+
--- @param path string
10+
--- @return boolean
911
local function is_valid_file(path)
10-
return vim.fn.filereadable(path) == 1 and vim.fn.getfsize(path) > 0
12+
return vim.fn.getfsize(path) > 0
1113
end
1214

13-
--- Try to extract image from macOS clipboard
14-
--- @param image_path string Path where to save the image
15-
--- @return boolean success True if image was extracted successfully
16-
local function try_macos_clipboard(image_path)
17-
if vim.fn.executable('osascript') ~= 1 then
18-
return false
19-
end
20-
21-
local osascript_cmd = string.format(
22-
'osascript -e \'set imageData to the clipboard as "PNGf"\' '
23-
.. '-e \'set fileRef to open for access POSIX file "%s" with write permission\' '
24-
.. "-e 'set eof fileRef to 0' "
25-
.. "-e 'write imageData to fileRef' "
26-
.. "-e 'close access fileRef'",
27-
image_path
28-
)
29-
30-
local result = vim.system({ 'sh', '-c', osascript_cmd }):wait()
31-
return result.code == 0 and is_valid_file(image_path)
15+
--- Run shell command and return success
16+
--- @param cmd string
17+
--- @return boolean
18+
local function run_shell_cmd(cmd)
19+
return vim.system({ 'sh', '-c', cmd }):wait().code == 0
3220
end
3321

34-
--- Try to extract image from Linux clipboard (Wayland)
35-
--- @param image_path string Path where to save the image
36-
--- @return boolean success True if image was extracted successfully
37-
local function try_wayland_clipboard(image_path)
38-
if vim.fn.executable('wl-paste') ~= 1 then
39-
return false
22+
--- Save base64 data to file
23+
--- @param data string
24+
--- @param path string
25+
--- @return boolean
26+
local function save_base64(data, path)
27+
if vim.fn.has('win32') == 1 then
28+
local script =
29+
string.format('[System.IO.File]::WriteAllBytes("%s", [System.Convert]::FromBase64String("%s"))', path, data)
30+
return vim.system({ 'powershell.exe', '-command', '-' }, { stdin = script }):wait().code == 0
31+
else
32+
local decode_arg = vim.uv.os_uname().sysname == 'Darwin' and '-D' or '-d'
33+
return vim.system({ 'sh', '-c', string.format('base64 %s > "%s"', decode_arg, path) }, { stdin = data }):wait().code
34+
== 0
4035
end
41-
42-
local cmd = string.format('wl-paste -t image/png > "%s"', image_path)
43-
local result = vim.system({ 'sh', '-c', cmd }):wait()
44-
return result.code == 0 and is_valid_file(image_path)
4536
end
4637

47-
--- Try to extract image from Linux clipboard (X11)
48-
--- @param image_path string Path where to save the image
49-
--- @return boolean success True if image was extracted successfully
50-
local function try_x11_clipboard(image_path)
51-
if vim.fn.executable('xclip') ~= 1 then
38+
--- macOS clipboard image handler using osascript
39+
--- @param path string
40+
--- @return boolean
41+
local function handle_darwin_clipboard(path)
42+
if vim.fn.executable('osascript') ~= 1 then
5243
return false
5344
end
45+
local cmd = string.format(
46+
"osascript -e 'set imageData to the clipboard as \"PNGf\"' -e 'set fileRef to open for access POSIX file \"%s\" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'",
47+
path
48+
)
49+
return run_shell_cmd(cmd)
50+
end
5451

55-
local cmd = string.format('xclip -selection clipboard -t image/png -o > "%s"', image_path)
56-
local result = vim.system({ 'sh', '-c', cmd }):wait()
57-
return result.code == 0 and is_valid_file(image_path)
52+
--- Linux clipboard image handler supporting Wayland and X11
53+
--- @param path string
54+
--- @return boolean
55+
local function handle_linux_clipboard(path)
56+
if vim.fn.executable('wl-paste') == 1 and run_shell_cmd(string.format('wl-paste -t image/png > "%s"', path)) then
57+
return true
58+
end
59+
return vim.fn.executable('xclip') == 1
60+
and run_shell_cmd(string.format('xclip -selection clipboard -t image/png -o > "%s"', path))
5861
end
5962

60-
--- Try to extract image from Windows/WSL clipboard
61-
--- @param image_path string Path where to save the image
62-
--- @return boolean success True if image was extracted successfully
63-
local function try_windows_clipboard(image_path)
63+
--- Windows clipboard image handler using PowerShell
64+
--- @param path string
65+
--- @return boolean
66+
local function handle_windows_clipboard(path)
6467
if vim.fn.executable('powershell.exe') ~= 1 then
6568
return false
6669
end
70+
local script = string.format(
71+
[[
72+
Add-Type -AssemblyName System.Windows.Forms;
73+
$img = [System.Windows.Forms.Clipboard]::GetImage();
74+
if ($img) {
75+
$img.Save('%s', [System.Drawing.Imaging.ImageFormat]::Png);
76+
} else {
77+
exit 1
78+
}
79+
]],
80+
path
81+
)
82+
return vim.system({ 'powershell.exe', '-command', '-' }, { stdin = script }):wait().code == 0
83+
end
6784

68-
local powershell_script = [[
69-
Add-Type -AssemblyName System.Windows.Forms;
70-
$img = [System.Windows.Forms.Clipboard]::GetImage();
71-
if ($img) {
72-
$ms = New-Object System.IO.MemoryStream;
73-
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);
74-
[System.Convert]::ToBase64String($ms.ToArray())
75-
}
76-
]]
77-
78-
local result = vim.system({ 'powershell.exe', '-command', powershell_script }):wait()
79-
if result.code ~= 0 or not result.stdout or result.stdout:gsub('%s', '') == '' then
80-
return false
85+
local handlers = {
86+
Darwin = handle_darwin_clipboard,
87+
Linux = handle_linux_clipboard,
88+
Windows_NT = handle_windows_clipboard,
89+
}
90+
91+
--- Try to get image from system clipboard
92+
--- @param image_path string
93+
--- @return boolean
94+
local function try_system_clipboard(image_path)
95+
local os_name = vim.uv.os_uname().sysname
96+
local handler = handlers[os_name]
97+
98+
-- WSL detection and override
99+
if vim.fn.exists('$WSL_DISTRO_NAME') == 1 then
100+
handler = handlers.Windows_NT
81101
end
82102

83-
local base64_data = result.stdout:gsub('%s', '')
84-
local decode_cmd = string.format('echo "%s" | base64 -d > "%s"', base64_data, image_path)
85-
local decode_result = vim.system({ 'sh', '-c', decode_cmd }):wait()
86-
return decode_result.code == 0 and is_valid_file(image_path)
103+
return handler and handler(image_path) and is_valid_file(image_path) or false
87104
end
88105

89-
--- Try to extract image from clipboard as base64 text
90-
--- @param temp_dir string Temporary directory
91-
--- @param timestamp string Timestamp for filename
92-
--- @return string|nil image_path Path to extracted image, or nil if failed
106+
--- Try to parse base64 image data from clipboard
107+
--- @param temp_dir string
108+
--- @param timestamp string
109+
--- @return boolean, string?
93110
local function try_base64_clipboard(temp_dir, timestamp)
94-
local clipboard_content = vim.fn.getreg('+')
95-
if not clipboard_content or not clipboard_content:match('^data:image/[^;]+;base64,') then
96-
return nil
111+
local content = vim.fn.getreg('+')
112+
if not content or content == '' then
113+
return false
97114
end
98115

99-
local format, base64_data = clipboard_content:match('^data:image/([^;]+);base64,(.+)$')
100-
if not format or not base64_data then
101-
return nil
116+
local format, data = content:match('^data:image/([^;]+);base64,(.+)$')
117+
if not format or not data then
118+
return false
102119
end
103120

104-
local image_path = temp_dir .. '/pasted_image_' .. timestamp .. '.' .. format
105-
local decode_cmd = string.format('echo "%s" | base64 -d > "%s"', base64_data, image_path)
106-
local result = vim.system({ 'sh', '-c', decode_cmd }):wait()
107-
108-
if result.code == 0 and is_valid_file(image_path) then
109-
return image_path
110-
end
111-
return nil
112-
end
121+
local image_path = string.format('%s/pasted_image_%s.%s', temp_dir, timestamp, format)
122+
local success = save_base64(data, image_path) and is_valid_file(image_path)
113123

114-
--- Get error message for missing clipboard tools
115-
--- @param os_name string Operating system name
116-
--- @return string error_message Error message with installation instructions
117-
local function get_clipboard_error_message(os_name)
118-
local install_msg = 'No image found in clipboard. Install clipboard tools: '
119-
if os_name == 'Linux' then
120-
return install_msg .. 'xclip (X11) or wl-clipboard (Wayland)'
121-
elseif os_name == 'Darwin' then
122-
return install_msg .. 'system clipboard should work natively'
123-
else
124-
return install_msg .. 'PowerShell (Windows/WSL)'
125-
end
124+
return success, success and image_path or nil
126125
end
127126

128127
--- Handle clipboard image data by saving it to a file and adding it to context
@@ -131,39 +130,27 @@ function M.paste_image_from_clipboard()
131130
local temp_dir = vim.fn.tempname()
132131
vim.fn.mkdir(temp_dir, 'p')
133132
local timestamp = os.date('%Y%m%d_%H%M%S')
134-
local image_path = temp_dir .. '/pasted_image_' .. timestamp .. '.png'
133+
local image_path = string.format('%s/pasted_image_%s.png', temp_dir, timestamp)
135134

136-
local os_name = vim.uv.os_uname().sysname
137-
local success = false
138-
139-
if os_name == 'Darwin' then
140-
success = try_macos_clipboard(image_path)
141-
elseif os_name == 'Windows_NT' or vim.fn.exists('$WSL_DISTRO_NAME') == 1 then
142-
success = try_windows_clipboard(image_path)
143-
elseif os_name == 'Linux' then
144-
success = try_wayland_clipboard(image_path) or try_x11_clipboard(image_path)
145-
end
135+
local success = try_system_clipboard(image_path)
146136

147137
if not success then
148-
local fallback_path = try_base64_clipboard(temp_dir, timestamp)
149-
if fallback_path then
150-
image_path = fallback_path
151-
success = true
138+
local base64_success, base64_path = try_base64_clipboard(temp_dir, timestamp --[[@as string]])
139+
success = base64_success
140+
if base64_path then
141+
image_path = base64_path
152142
end
153143
end
154144

155-
if not success then
156-
vim.notify(get_clipboard_error_message(os_name), vim.log.levels.WARN)
157-
return false
145+
if success then
146+
context.add_file(image_path)
147+
state.context_updated_at = os.time()
148+
vim.notify('Image saved and added to context: ' .. vim.fn.fnamemodify(image_path, ':t'), vim.log.levels.INFO)
149+
return true
158150
end
159151

160-
context.add_file(image_path)
161-
state.context_updated_at = os.time()
162-
163-
local filename = vim.fn.fnamemodify(image_path, ':t')
164-
vim.notify(string.format('Image saved and added to context: %s', filename), vim.log.levels.INFO)
165-
166-
return true
152+
vim.notify('No image found in clipboard.', vim.log.levels.WARN)
153+
return false
167154
end
168155

169156
return M

lua/opencode/types.lua

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11

2+
---@class OpencodeDiagnostic
3+
---@field message string
4+
---@field severity number
5+
---@field lnum number
6+
---@field col number
7+
---@field end_lnum? number
8+
---@field end_col? number
9+
---@field source? string
10+
---@field code? string|number
11+
---@field user_data? any
12+
213
---@class OpencodeConfigFile
314
---@field theme string
415
---@field autoshare boolean
@@ -333,7 +344,7 @@
333344
---@field mentioned_files string[]|nil
334345
---@field mentioned_subagents string[]|nil
335346
---@field selections OpencodeContextSelection[]|nil
336-
---@field linter_errors vim.Diagnostic[]|nil
347+
---@field linter_errors OpencodeDiagnostic[]|nil
337348

338349
---@class OpencodeContextSelection
339350
---@field file OpencodeContextFile

lua/opencode/ui/formatter.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ function M._format_diagnostics_context(output, part)
379379
return
380380
end
381381
local start_line = output:get_line_count()
382-
local diagnostics = json.content --[[@as vim.Diagnostic[] ]]
382+
local diagnostics = json.content --[[@as OpencodeDiagnostic[] ]]
383383
if not diagnostics or type(diagnostics) ~= 'table' or #diagnostics == 0 then
384384
return
385385
end

lua/opencode/util.lua

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,32 @@ function M.pcall_trace(fn, ...)
382382
end, ...)
383383
end
384384

385+
function M.is_path_in_cwd(path)
386+
local cwd = vim.fn.getcwd()
387+
local abs_path = vim.fn.fnamemodify(path, ':p')
388+
return abs_path:sub(1, #cwd) == cwd
389+
end
390+
391+
--- Check if a given path is in the system temporary directory.
392+
--- Optionally match the filename against a pattern.
393+
--- @param path string File path to check
394+
--- @param pattern string|nil Optional Lua pattern to match the filename
395+
--- @return boolean is_temp
396+
function M.is_temp_path(path, pattern)
397+
local temp_dir = vim.fn.tempname()
398+
temp_dir = vim.fn.fnamemodify(temp_dir, ':h')
399+
400+
local abs_path = vim.fn.fnamemodify(path, ':p')
401+
if abs_path:sub(1, #temp_dir) ~= temp_dir then
402+
return false
403+
end
404+
405+
if pattern then
406+
local filename = vim.fn.fnamemodify(path, ':t')
407+
return filename:match(pattern) ~= nil
408+
end
409+
410+
return true
411+
end
412+
385413
return M

tests/unit/context_spec.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,16 @@ describe('add_file/add_selection/add_subagent', function()
120120
vim.fn.filereadable = function()
121121
return 1
122122
end
123+
local util = require('opencode.util')
124+
local original_is_path_in_cwd = util.is_path_in_cwd
125+
util.is_path_in_cwd = function()
126+
return true
127+
end
128+
123129
context.add_file('/tmp/foo.lua')
124130
assert.same({ '/tmp/foo.lua' }, context.context.mentioned_files)
131+
132+
util.is_path_in_cwd = original_is_path_in_cwd
125133
end)
126134
it('does not add file if not filereadable', function()
127135
vim.fn.filereadable = function()

0 commit comments

Comments
 (0)