Skip to content

Commit 8955d45

Browse files
committed
Support mentioning filenames with spaces
1 parent 84c703e commit 8955d45

File tree

8 files changed

+678
-512
lines changed

8 files changed

+678
-512
lines changed
Lines changed: 165 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,187 +1,198 @@
1-
// Create mock vscode module before importing anything
2-
const createMockUri = (scheme: string, path: string) => ({
3-
scheme,
4-
authority: "",
5-
path,
6-
query: "",
7-
fragment: "",
8-
fsPath: path,
9-
with: jest.fn(),
10-
toString: () => path,
11-
toJSON: () => ({
12-
scheme,
13-
authority: "",
14-
path,
15-
query: "",
16-
fragment: "",
17-
}),
18-
})
1+
import * as vscode from "vscode"
2+
import * as path from "path"
3+
import fs from "fs/promises"
4+
import { openFile } from "../../../integrations/misc/open-file"
5+
import { getWorkspacePath } from "../../../utils/path"
6+
// Test through exported functions parseMentions and openMention
7+
import { parseMentions, openMention } from "../index"
8+
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
199

20-
const mockExecuteCommand = jest.fn()
21-
const mockOpenExternal = jest.fn()
22-
const mockShowErrorMessage = jest.fn()
23-
24-
const mockVscode = {
25-
workspace: {
26-
workspaceFolders: [
27-
{
28-
uri: { fsPath: "/test/workspace" },
29-
},
30-
] as { uri: { fsPath: string } }[] | undefined,
31-
getWorkspaceFolder: jest.fn().mockReturnValue("/test/workspace"),
32-
fs: {
33-
stat: jest.fn(),
34-
writeFile: jest.fn(),
10+
// Mocks
11+
jest.mock("fs/promises", () => ({
12+
stat: jest.fn(),
13+
readdir: jest.fn(),
14+
}))
15+
jest.mock("../../../integrations/misc/open-file", () => ({
16+
openFile: jest.fn(),
17+
}))
18+
jest.mock("../../../utils/path", () => ({
19+
getWorkspacePath: jest.fn(),
20+
}))
21+
jest.mock(
22+
"vscode",
23+
() => ({
24+
commands: {
25+
executeCommand: jest.fn(),
3526
},
36-
openTextDocument: jest.fn().mockResolvedValue({}),
37-
},
38-
window: {
39-
showErrorMessage: mockShowErrorMessage,
40-
showInformationMessage: jest.fn(),
41-
showWarningMessage: jest.fn(),
42-
createTextEditorDecorationType: jest.fn(),
43-
createOutputChannel: jest.fn(),
44-
createWebviewPanel: jest.fn(),
45-
showTextDocument: jest.fn().mockResolvedValue({}),
46-
activeTextEditor: undefined as
47-
| undefined
48-
| {
49-
document: {
50-
uri: { fsPath: string }
51-
}
52-
},
53-
},
54-
commands: {
55-
executeCommand: mockExecuteCommand,
56-
},
57-
env: {
58-
openExternal: mockOpenExternal,
59-
},
60-
Uri: {
61-
parse: jest.fn((url: string) => createMockUri("https", url)),
62-
file: jest.fn((path: string) => createMockUri("file", path)),
63-
},
64-
Position: jest.fn(),
65-
Range: jest.fn(),
66-
TextEdit: jest.fn(),
67-
WorkspaceEdit: jest.fn(),
68-
DiagnosticSeverity: {
69-
Error: 0,
70-
Warning: 1,
71-
Information: 2,
72-
Hint: 3,
73-
},
74-
}
75-
76-
// Mock modules
77-
jest.mock("vscode", () => mockVscode)
27+
Uri: {
28+
file: jest.fn((p) => ({ fsPath: p })), // Simple mock for Uri.file
29+
},
30+
window: {
31+
showErrorMessage: jest.fn(),
32+
},
33+
env: {
34+
openExternal: jest.fn(),
35+
},
36+
}),
37+
{ virtual: true },
38+
)
39+
jest.mock("../../../integrations/misc/extract-text", () => ({
40+
extractTextFromFile: jest.fn(),
41+
}))
42+
jest.mock("isbinaryfile", () => ({
43+
isBinaryFile: jest.fn().mockResolvedValue(false),
44+
}))
7845
jest.mock("../../../services/browser/UrlContentFetcher")
79-
jest.mock("../../../utils/git")
80-
jest.mock("../../../utils/path")
8146

82-
// Now import the modules that use the mocks
83-
import { parseMentions, openMention } from "../index"
84-
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
85-
import * as git from "../../../utils/git"
86-
87-
import { getWorkspacePath } from "../../../utils/path"
88-
;(getWorkspacePath as jest.Mock).mockReturnValue("/test/workspace")
47+
// Helper to reset mocks between tests
48+
const resetMocks = () => {
49+
;(fs.stat as jest.Mock).mockClear()
50+
;(fs.readdir as jest.Mock).mockClear()
51+
;(openFile as jest.Mock).mockClear()
52+
;(getWorkspacePath as jest.Mock).mockClear()
53+
;(vscode.commands.executeCommand as jest.Mock).mockClear()
54+
;(vscode.Uri.file as jest.Mock).mockClear()
55+
;(require("../../../integrations/misc/extract-text").extractTextFromFile as jest.Mock).mockClear()
56+
;(require("isbinaryfile").isBinaryFile as jest.Mock).mockClear()
57+
}
8958

90-
describe("mentions", () => {
91-
const mockCwd = "/test/workspace"
92-
let mockUrlContentFetcher: UrlContentFetcher
59+
describe("Core Mentions Logic", () => {
60+
const MOCK_CWD = "/mock/workspace"
9361

9462
beforeEach(() => {
95-
jest.clearAllMocks()
96-
97-
// Create a mock instance with just the methods we need
98-
mockUrlContentFetcher = {
99-
launchBrowser: jest.fn().mockResolvedValue(undefined),
100-
closeBrowser: jest.fn().mockResolvedValue(undefined),
101-
urlToMarkdown: jest.fn().mockResolvedValue(""),
102-
} as unknown as UrlContentFetcher
103-
104-
// Reset all vscode mocks
105-
mockVscode.workspace.fs.stat.mockReset()
106-
mockVscode.workspace.fs.writeFile.mockReset()
107-
mockVscode.workspace.openTextDocument.mockReset().mockResolvedValue({})
108-
mockVscode.window.showTextDocument.mockReset().mockResolvedValue({})
109-
mockVscode.window.showErrorMessage.mockReset()
110-
mockExecuteCommand.mockReset()
111-
mockOpenExternal.mockReset()
63+
resetMocks()
64+
// Default mock implementations
65+
;(getWorkspacePath as jest.Mock).mockReturnValue(MOCK_CWD)
11266
})
11367

11468
describe("parseMentions", () => {
115-
it("should parse git commit mentions", async () => {
116-
const commitHash = "abc1234"
117-
const commitInfo = `abc1234 Fix bug in parser
69+
let mockUrlFetcher: UrlContentFetcher
11870

119-
Author: John Doe
120-
Date: Mon Jan 5 23:50:06 2025 -0500
71+
beforeEach(() => {
72+
mockUrlFetcher = new (UrlContentFetcher as jest.Mock<UrlContentFetcher>)()
73+
;(fs.stat as jest.Mock).mockResolvedValue({ isFile: () => true, isDirectory: () => false })
74+
;(require("../../../integrations/misc/extract-text").extractTextFromFile as jest.Mock).mockResolvedValue(
75+
"Mock file content",
76+
)
77+
})
12178

122-
Detailed commit message with multiple lines
123-
- Fixed parsing issue
124-
- Added tests`
79+
it("should correctly parse mentions with escaped spaces and fetch content", async () => {
80+
const text = "Please check the file @/path/to/file\\ with\\ spaces.txt"
81+
const mentionPath = "/path/to/file\\ with\\ spaces.txt"
82+
const expectedUnescaped = "path/to/file with spaces.txt" // Note: leading '/' removed by slice(1) in parseMentions
83+
const expectedAbsPath = path.resolve(MOCK_CWD, expectedUnescaped)
12584

126-
jest.mocked(git.getCommitInfo).mockResolvedValue(commitInfo)
85+
const result = await parseMentions(text, MOCK_CWD, mockUrlFetcher)
12786

128-
const result = await parseMentions(`Check out this commit @${commitHash}`, mockCwd, mockUrlContentFetcher)
87+
// Check if fs.stat was called with the unescaped path
88+
expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
89+
// Check if extractTextFromFile was called with the unescaped path
90+
expect(require("../../../integrations/misc/extract-text").extractTextFromFile).toHaveBeenCalledWith(
91+
expectedAbsPath,
92+
)
12993

130-
expect(result).toContain(`'${commitHash}' (see below for commit info)`)
131-
expect(result).toContain(`<git_commit hash="${commitHash}">`)
132-
expect(result).toContain(commitInfo)
94+
// Check the output format
95+
expect(result).toContain(`'path/to/file\\ with\\ spaces.txt' (see below for file content)`)
96+
expect(result).toContain(
97+
`<file_content path="path/to/file\\ with\\ spaces.txt">\nMock file content\n</file_content>`,
98+
)
13399
})
134100

135-
it("should handle errors fetching git info", async () => {
136-
const commitHash = "abc1234"
137-
const errorMessage = "Failed to get commit info"
101+
it("should handle folder mentions with escaped spaces", async () => {
102+
const text = "Look in @/my\\ documents/folder\\ name/"
103+
const mentionPath = "/my\\ documents/folder\\ name/"
104+
const expectedUnescaped = "my documents/folder name/"
105+
const expectedAbsPath = path.resolve(MOCK_CWD, expectedUnescaped)
106+
;(fs.stat as jest.Mock).mockResolvedValue({ isFile: () => false, isDirectory: () => true })
107+
;(fs.readdir as jest.Mock).mockResolvedValue([]) // Empty directory
108+
109+
const result = await parseMentions(text, MOCK_CWD, mockUrlFetcher)
138110

139-
jest.mocked(git.getCommitInfo).mockRejectedValue(new Error(errorMessage))
111+
expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
112+
expect(fs.readdir).toHaveBeenCalledWith(expectedAbsPath, { withFileTypes: true })
113+
expect(result).toContain(`'my\\ documents/folder\\ name/' (see below for folder content)`)
114+
expect(result).toContain(`<folder_content path="my\\ documents/folder\\ name/">`) // Content check might be more complex
115+
})
140116

141-
const result = await parseMentions(`Check out this commit @${commitHash}`, mockCwd, mockUrlContentFetcher)
117+
it("should handle errors when accessing paths with escaped spaces", async () => {
118+
const text = "Check @/nonexistent\\ file.txt"
119+
const mentionPath = "/nonexistent\\ file.txt"
120+
const expectedUnescaped = "nonexistent file.txt"
121+
const expectedAbsPath = path.resolve(MOCK_CWD, expectedUnescaped)
122+
const mockError = new Error("ENOENT: no such file or directory")
123+
;(fs.stat as jest.Mock).mockRejectedValue(mockError)
142124

143-
expect(result).toContain(`'${commitHash}' (see below for commit info)`)
144-
expect(result).toContain(`<git_commit hash="${commitHash}">`)
145-
expect(result).toContain(`Error fetching commit info: ${errorMessage}`)
125+
const result = await parseMentions(text, MOCK_CWD, mockUrlFetcher)
126+
127+
expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
128+
expect(result).toContain(
129+
`<file_content path="nonexistent\\ file.txt">\nError fetching content: Failed to access path "nonexistent\\ file.txt": ${mockError.message}\n</file_content>`,
130+
)
146131
})
132+
133+
// Add more tests for parseMentions if needed (URLs, other mentions combined with escaped paths etc.)
147134
})
148135

149136
describe("openMention", () => {
150-
it("should handle file paths and problems", async () => {
151-
// Mock stat to simulate file not existing
152-
mockVscode.workspace.fs.stat.mockRejectedValueOnce(new Error("File does not exist"))
137+
beforeEach(() => {
138+
;(getWorkspacePath as jest.Mock).mockReturnValue(MOCK_CWD)
139+
})
140+
141+
it("should unescape file path before opening", async () => {
142+
const mention = "/file\\ with\\ spaces.txt"
143+
const expectedUnescaped = "file with spaces.txt"
144+
const expectedAbsPath = path.resolve(MOCK_CWD, expectedUnescaped)
153145

154-
// Call openMention and wait for it to complete
155-
await openMention("/path/to/file")
146+
await openMention(mention)
147+
148+
expect(openFile).toHaveBeenCalledWith(expectedAbsPath)
149+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
150+
})
156151

157-
// Verify error handling
158-
expect(mockExecuteCommand).not.toHaveBeenCalled()
159-
expect(mockOpenExternal).not.toHaveBeenCalled()
160-
expect(mockVscode.window.showErrorMessage).toHaveBeenCalledWith("Could not open file: File does not exist")
152+
it("should unescape folder path before revealing", async () => {
153+
const mention = "/folder\\ with\\ spaces/"
154+
const expectedUnescaped = "folder with spaces/"
155+
const expectedAbsPath = path.resolve(MOCK_CWD, expectedUnescaped)
156+
const expectedUri = { fsPath: expectedAbsPath } // From mock
157+
;(vscode.Uri.file as jest.Mock).mockReturnValue(expectedUri)
161158

162-
// Reset mocks for next test
163-
jest.clearAllMocks()
159+
await openMention(mention)
164160

165-
// Test problems command
166-
await openMention("problems")
167-
expect(mockExecuteCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
161+
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealInExplorer", expectedUri)
162+
expect(vscode.Uri.file).toHaveBeenCalledWith(expectedAbsPath)
163+
expect(openFile).not.toHaveBeenCalled()
168164
})
169165

170-
it("should handle URLs", async () => {
171-
const url = "https://example.com"
172-
await openMention(url)
173-
const mockUri = mockVscode.Uri.parse(url)
174-
expect(mockVscode.env.openExternal).toHaveBeenCalled()
175-
const calledArg = mockVscode.env.openExternal.mock.calls[0][0]
176-
expect(calledArg).toEqual(
177-
expect.objectContaining({
178-
scheme: mockUri.scheme,
179-
authority: mockUri.authority,
180-
path: mockUri.path,
181-
query: mockUri.query,
182-
fragment: mockUri.fragment,
183-
}),
184-
)
166+
it("should handle mentions without paths correctly", async () => {
167+
await openMention("@problems")
168+
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
169+
170+
await openMention("@terminal")
171+
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.terminal.focus")
172+
173+
await openMention("@http://example.com")
174+
expect(vscode.env.openExternal).toHaveBeenCalled() // Check if called, specific URI mock might be needed for detailed check
175+
176+
await openMention("@git-changes") // Assuming no specific action for this yet
177+
// Add expectations if an action is defined for git-changes
178+
179+
await openMention("@a1b2c3d") // Assuming no specific action for commit hashes yet
180+
// Add expectations if an action is defined for commit hashes
181+
})
182+
183+
it("should do nothing if mention is undefined or empty", async () => {
184+
await openMention(undefined)
185+
await openMention("")
186+
expect(openFile).not.toHaveBeenCalled()
187+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
188+
expect(vscode.env.openExternal).not.toHaveBeenCalled()
189+
})
190+
191+
it("should do nothing if cwd is not available", async () => {
192+
;(getWorkspacePath as jest.Mock).mockReturnValue(undefined)
193+
await openMention("/some\\ path.txt")
194+
expect(openFile).not.toHaveBeenCalled()
195+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
185196
})
186197
})
187198
})

src/core/mentions/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export async function openMention(mention?: string): Promise<void> {
2222
}
2323

2424
if (mention.startsWith("/")) {
25-
const relPath = mention.slice(1)
25+
// Slice off the leading slash and unescape any spaces in the path
26+
const relPath = unescapeSpaces(mention.slice(1))
2627
const absPath = path.resolve(cwd, relPath)
2728
if (mention.endsWith("/")) {
2829
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
@@ -145,8 +146,15 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
145146
return parsedText
146147
}
147148

149+
// Helper function to unescape paths with backslash-escaped spaces
150+
function unescapeSpaces(path: string): string {
151+
return path.replace(/\\ /g, " ")
152+
}
153+
148154
async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise<string> {
149-
const absPath = path.resolve(cwd, mentionPath)
155+
// Unescape spaces in the path before resolving it
156+
const unescapedPath = unescapeSpaces(mentionPath)
157+
const absPath = path.resolve(cwd, unescapedPath)
150158

151159
try {
152160
const stats = await fs.stat(absPath)

0 commit comments

Comments
 (0)