Skip to content

Commit 5d4fe3b

Browse files
authored
Show --gif expressions in side WebView and fix tab alignment issues (#46)
1 parent 618acd7 commit 5d4fe3b

File tree

1 file changed

+307
-5
lines changed

1 file changed

+307
-5
lines changed

src/ccs/commands/contextHelp.ts

Lines changed: 307 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as path from "path";
2+
import { URL } from "url";
23
import * as vscode from "vscode";
34

45
import { ContextExpressionClient } from "../sourcecontrol/clients/contextExpressionClient";
@@ -30,14 +31,58 @@ export async function resolveContextExpression(): Promise<void> {
3031

3132
if (typeof data.status === "string" && data.status.toLowerCase() === "success" && data.textExpression) {
3233
const eol = document.eol === vscode.EndOfLine.CRLF ? "\r\n" : "\n";
33-
const textExpression = data.textExpression.replace(/\r?\n/g, eol);
34-
const formattedTextExpression = textExpression.replace(/^/, "\t");
35-
const rangeToReplace = selection.isEmpty
36-
? document.lineAt(selection.active.line).range
37-
: new vscode.Range(selection.start, selection.end);
34+
let normalizedTextExpression = data.textExpression.replace(/\r?\n/g, "\n");
35+
let gifUri: vscode.Uri | undefined;
36+
37+
if (/--gif\b/i.test(contextExpression)) {
38+
const extracted = extractGifUri(normalizedTextExpression);
39+
normalizedTextExpression = extracted.textWithoutGifUri;
40+
gifUri = extracted.gifUri;
41+
}
42+
43+
const textExpression = normalizedTextExpression.replace(/\r?\n/g, eol);
44+
let formattedTextExpression = textExpression;
45+
46+
let rangeToReplace: vscode.Range;
47+
if (selection.isEmpty) {
48+
const fallbackLine = document.lineAt(selection.active.line);
49+
const fallbackRange = fallbackLine.range;
50+
51+
rangeToReplace = getRangeToReplaceForLine(document, selection.active.line, contextExpression) ?? fallbackRange;
52+
53+
const preservedPrefix = document.getText(new vscode.Range(fallbackLine.range.start, rangeToReplace.start));
54+
55+
formattedTextExpression = normalizeInsertionWithPrefix(formattedTextExpression, preservedPrefix, eol);
56+
} else {
57+
// Multi-line or partial selection
58+
const firstSelLine = document.lineAt(selection.start.line);
59+
const preservedPrefix = document.getText(new vscode.Range(firstSelLine.range.start, selection.start));
60+
const leadingWS = firstSelLine.text.match(/^[\t ]*/)?.[0] ?? "";
61+
62+
// 1) Normalize snippet to avoid duplicating "."/";" according to the prefix that will remain in the file
63+
formattedTextExpression = normalizeInsertionWithPrefix(formattedTextExpression, preservedPrefix, eol);
64+
65+
// 2) Only prefix indentation if the selection started at column 0 (i.e., NO preserved prefix)
66+
formattedTextExpression = maybePrefixFirstLineIndent(
67+
formattedTextExpression,
68+
preservedPrefix.length === 0 ? leadingWS : "",
69+
eol
70+
);
71+
72+
rangeToReplace = new vscode.Range(selection.start, selection.end);
73+
}
74+
3875
await editor.edit((editBuilder) => {
3976
editBuilder.replace(rangeToReplace, formattedTextExpression);
4077
});
78+
79+
if (gifUri) {
80+
try {
81+
await showGifInWebview(gifUri);
82+
} catch (error) {
83+
handleError(error, "Failed to open GIF from context expression.");
84+
}
85+
}
4186
} else {
4287
const errorMessage = data.message || "Failed to resolve context expression.";
4388
void vscode.window.showErrorMessage(errorMessage);
@@ -46,3 +91,260 @@ export async function resolveContextExpression(): Promise<void> {
4691
handleError(error, "Failed to resolve context expression.");
4792
}
4893
}
94+
95+
function getRangeToReplaceForLine(
96+
document: vscode.TextDocument,
97+
lineNumber: number,
98+
contextExpression: string
99+
): vscode.Range | undefined {
100+
if (!contextExpression) {
101+
return undefined;
102+
}
103+
104+
const line = document.lineAt(lineNumber);
105+
const expressionIndex = line.text.indexOf(contextExpression);
106+
if (expressionIndex === -1) {
107+
return undefined;
108+
}
109+
110+
const prefixLength = getPrefixLengthToPreserve(contextExpression);
111+
const startCharacter = expressionIndex + prefixLength;
112+
const endCharacter = expressionIndex + contextExpression.length;
113+
114+
const start = line.range.start.translate(0, startCharacter);
115+
const end = line.range.start.translate(0, endCharacter);
116+
return new vscode.Range(start, end);
117+
}
118+
119+
/**
120+
* Based on the preserved line prefix, remove from the BEGINNING of the snippet's first line:
121+
* - if the prefix ends with ";": remove ^[\t ]*(?:\.\s*)*;\s*
122+
* - otherwise, if it ends with dots: remove ^[\t ]*(?:\.\s*)+
123+
* - neutral case: try to remove comment; otherwise remove dots
124+
*/
125+
function normalizeInsertionWithPrefix(text: string, preservedPrefix: string, eol: string): string {
126+
const lines = text.split(/\r?\n/);
127+
if (lines.length === 0) return text;
128+
129+
const preservedEnd = preservedPrefix.replace(/\s+$/g, "");
130+
131+
const endsWithSemicolon = /(?:\.\s*)*;\s*$/.test(preservedEnd);
132+
const endsWithDotsOnly = !endsWithSemicolon && /(?:\.\s*)+$/.test(preservedEnd);
133+
134+
if (endsWithSemicolon) {
135+
lines[0] = lines[0].replace(/^[\t ]*(?:\.\s*)*;\s*/, "");
136+
} else if (endsWithDotsOnly) {
137+
lines[0] = lines[0].replace(/^[\t ]*(?:\.\s*)+/, "");
138+
} else {
139+
const removedComment = lines[0].replace(/^[\t ]*(?:\.\s*)?;\s*/, "");
140+
if (removedComment !== lines[0]) {
141+
lines[0] = removedComment;
142+
} else {
143+
lines[0] = lines[0].replace(/^[\t ]*(?:\.\s*)+/, "");
144+
}
145+
}
146+
147+
return lines.join(eol);
148+
}
149+
150+
/**
151+
* Prefix indentation (tabs/spaces) ONLY if provided.
152+
* Useful when the selection started at column 0 (no preserved prefix).
153+
*/
154+
function maybePrefixFirstLineIndent(text: string, leadingWS: string, eol: string): string {
155+
if (!text || !leadingWS) return text;
156+
const lines = text.split(/\r?\n/);
157+
if (lines.length === 0) return text;
158+
159+
// Do not force replacement if there is already some whitespace; just prefix it.
160+
lines[0] = leadingWS + lines[0];
161+
return lines.join(eol);
162+
}
163+
164+
/**
165+
* Keep: preserve level dots / indentation and, if present, '; ' before the typed content.
166+
* Returns how many characters of the contextExpression belong to that prefix.
167+
*/
168+
function getPrefixLengthToPreserve(contextExpression: string): number {
169+
let index = 0;
170+
171+
while (index < contextExpression.length) {
172+
const char = contextExpression[index];
173+
174+
if (char === ".") {
175+
index++;
176+
while (index < contextExpression.length && contextExpression[index] === " ") {
177+
index++;
178+
}
179+
continue;
180+
}
181+
182+
if (char === " " || char === "\t") {
183+
index++;
184+
continue;
185+
}
186+
187+
break;
188+
}
189+
190+
if (index < contextExpression.length && contextExpression[index] === ";") {
191+
index++;
192+
while (
193+
index < contextExpression.length &&
194+
(contextExpression[index] === " " || contextExpression[index] === "\t")
195+
) {
196+
index++;
197+
}
198+
}
199+
200+
return index;
201+
}
202+
203+
function extractGifUri(text: string): {
204+
textWithoutGifUri: string;
205+
gifUri?: vscode.Uri;
206+
} {
207+
const fileUriPattern = /file:\/\/\S+/i;
208+
const lines = text.split(/\r?\n/);
209+
const processedLines: string[] = [];
210+
let gifUri: vscode.Uri | undefined;
211+
212+
for (const line of lines) {
213+
if (!gifUri) {
214+
fileUriPattern.lastIndex = 0;
215+
const match = fileUriPattern.exec(line);
216+
if (match) {
217+
const candidate = getFileUriFromText(match[0]);
218+
if (candidate) {
219+
gifUri = candidate;
220+
const before = line.slice(0, match.index);
221+
const after = line.slice(match.index + match[0].length);
222+
const cleanedLine = `${before}${after}`;
223+
processedLines.push(cleanedLine);
224+
continue;
225+
}
226+
}
227+
}
228+
229+
processedLines.push(line);
230+
}
231+
232+
return { textWithoutGifUri: processedLines.join("\n"), gifUri };
233+
}
234+
235+
function getFileUriFromText(text: string): vscode.Uri | undefined {
236+
const trimmed = text.trim();
237+
if (!trimmed.toLowerCase().startsWith("file://")) {
238+
return undefined;
239+
}
240+
241+
try {
242+
const asUrl = new URL(trimmed.replace(/\\/g, "/"));
243+
if (asUrl.protocol !== "file:") {
244+
return undefined;
245+
}
246+
247+
let fsPath = decodeURIComponent(asUrl.pathname);
248+
if (/^\/[a-zA-Z]:/.test(fsPath)) {
249+
fsPath = fsPath.slice(1);
250+
}
251+
252+
return vscode.Uri.file(fsPath);
253+
} catch (error) {
254+
const withoutScheme = trimmed.replace(/^file:\/\//i, "");
255+
if (!withoutScheme) {
256+
return undefined;
257+
}
258+
259+
const decoded = decodeURIComponent(withoutScheme);
260+
const windowsMatch = decoded.match(/^\/?([a-zA-Z]:.*)$/);
261+
let pathToUse: string;
262+
if (windowsMatch) {
263+
pathToUse = windowsMatch[1];
264+
} else if (decoded.startsWith("/")) {
265+
pathToUse = decoded;
266+
} else {
267+
pathToUse = `/${decoded}`;
268+
}
269+
270+
try {
271+
return vscode.Uri.file(pathToUse);
272+
} catch (_error) {
273+
return undefined;
274+
}
275+
}
276+
}
277+
278+
async function showGifInWebview(gifUri: vscode.Uri): Promise<void> {
279+
await vscode.workspace.fs.stat(gifUri);
280+
281+
const title = path.basename(gifUri.fsPath);
282+
const panel = vscode.window.createWebviewPanel(
283+
"contextHelpGif",
284+
title,
285+
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: false },
286+
{
287+
enableScripts: false,
288+
retainContextWhenHidden: false,
289+
enableFindWidget: false,
290+
localResourceRoots: [vscode.Uri.file(path.dirname(gifUri.fsPath))],
291+
}
292+
);
293+
294+
panel.webview.html = getGifWebviewHtml(panel.webview, gifUri, title);
295+
}
296+
297+
function getGifWebviewHtml(webview: vscode.Webview, gifUri: vscode.Uri, title: string): string {
298+
const escapedTitle = escapeHtml(title);
299+
const gifSource = escapeHtml(webview.asWebviewUri(gifUri).toString());
300+
const cspSource = escapeHtml(webview.cspSource);
301+
302+
return `<!DOCTYPE html>
303+
<html lang="en">
304+
<head>
305+
<meta charset="UTF-8" />
306+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${cspSource} data:;" />
307+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
308+
<title>${escapedTitle}</title>
309+
<style>
310+
body {
311+
margin: 0;
312+
padding: 0;
313+
background-color: #1e1e1e;
314+
display: flex;
315+
align-items: center;
316+
justify-content: center;
317+
height: 100vh;
318+
}
319+
320+
img {
321+
max-width: 100%;
322+
max-height: 100%;
323+
object-fit: contain;
324+
}
325+
</style>
326+
</head>
327+
<body>
328+
<img src="${gifSource}" alt="${escapedTitle}" />
329+
</body>
330+
</html>`;
331+
}
332+
333+
function escapeHtml(input: string): string {
334+
return input.replace(/[&<>"']/g, (char) => {
335+
switch (char) {
336+
case "&":
337+
return "&amp;";
338+
case "<":
339+
return "&lt;";
340+
case ">":
341+
return "&gt;";
342+
case '"':
343+
return "&quot;";
344+
case "'":
345+
return "&#39;";
346+
default:
347+
return char;
348+
}
349+
});
350+
}

0 commit comments

Comments
 (0)