11import * as path from "path" ;
2+ import { URL } from "url" ;
23import * as vscode from "vscode" ;
34
45import { 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 ( / - - g i f \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 = / f i l e : \/ \/ \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 - z A - Z ] : / . test ( fsPath ) ) {
249+ fsPath = fsPath . slice ( 1 ) ;
250+ }
251+
252+ return vscode . Uri . file ( fsPath ) ;
253+ } catch ( error ) {
254+ const withoutScheme = trimmed . replace ( / ^ f i l e : \/ \/ / i, "" ) ;
255+ if ( ! withoutScheme ) {
256+ return undefined ;
257+ }
258+
259+ const decoded = decodeURIComponent ( withoutScheme ) ;
260+ const windowsMatch = decoded . match ( / ^ \/ ? ( [ a - z A - 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 "&" ;
338+ case "<" :
339+ return "<" ;
340+ case ">" :
341+ return ">" ;
342+ case '"' :
343+ return """ ;
344+ case "'" :
345+ return "'" ;
346+ default :
347+ return char ;
348+ }
349+ } ) ;
350+ }
0 commit comments