@@ -87,13 +87,7 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
8787 if ( e . type === 'ready' && ! isImageSent ) {
8888 isImageSent = true
8989 this . postInitSettings ( panel )
90-
91- if ( uri . path . toLowerCase ( ) . endsWith ( '.mhd' ) ) {
92- await NiiVueEditorProvider . sendMhdMessage ( uri , panel . webview )
93- } else {
94- const body = await NiiVueEditorProvider . uriToImageBody ( uri , panel . webview )
95- panel . webview . postMessage ( { type : 'addImage' , body } )
96- }
90+ await NiiVueEditorProvider . sendInitialImage ( uri , panel . webview )
9791 }
9892 } )
9993 } )
@@ -203,18 +197,30 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
203197 }
204198
205199 public static async openDcmFolder ( folderUri : vscode . Uri , panel : vscode . WebviewPanel ) {
200+ const series = await NiiVueEditorProvider . collectDicomFolder ( folderUri )
201+ if ( series . uris . length > 0 ) {
202+ panel . webview . postMessage ( {
203+ type : 'addImage' ,
204+ body : { uri : series . uris , data : series . datas } ,
205+ } )
206+ return
207+ }
208+ // Fallback: nothing sniffed as DICOM (e.g. files that lack the Part 10
209+ // preamble). Send every file in the folder and let the loader decide.
206210 const files = await vscode . workspace . fs . readDirectory ( folderUri )
207- const fileUris = files . map ( ( file ) => vscode . Uri . joinPath ( folderUri , file [ 0 ] ) )
211+ const fileUris = files
212+ . filter ( ( [ , fileType ] ) => ( fileType & vscode . FileType . File ) !== 0 )
213+ . map ( ( [ name ] ) => vscode . Uri . joinPath ( folderUri , name ) )
208214 const data = await Promise . all (
209215 fileUris . map ( ( uri ) =>
210- vscode . workspace . fs . readFile ( uri ) . then ( ( data ) => NiiVueEditorProvider . toArrayBuffer ( data ) ) ,
216+ vscode . workspace . fs . readFile ( uri ) . then ( ( d ) => NiiVueEditorProvider . toArrayBuffer ( d ) ) ,
211217 ) ,
212218 )
213219 panel . webview . postMessage ( {
214220 type : 'addImage' ,
215221 body : {
216- data : data ,
217- uri : fileUris [ 0 ] . toString ( ) ,
222+ data,
223+ uri : fileUris . map ( ( u ) => u . toString ( ) ) ,
218224 } ,
219225 } )
220226 }
@@ -244,21 +250,58 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
244250 webviewPanel . webview . onDidReceiveMessage ( async ( message ) => {
245251 if ( message . type === 'ready' ) {
246252 NiiVueEditorProvider . postInitSettings ( webviewPanel )
247-
248- if ( document . uri . path . toLowerCase ( ) . endsWith ( '.mhd' ) ) {
249- // MHD files have detached raw data; resolve the paired .raw file.
250- await NiiVueEditorProvider . sendMhdMessage ( document . uri , webviewPanel . webview )
251- } else {
252- const body = await NiiVueEditorProvider . uriToImageBody (
253- document . uri ,
254- webviewPanel . webview ,
255- )
256- webviewPanel . webview . postMessage ( { type : 'addImage' , body } )
257- }
253+ await NiiVueEditorProvider . sendInitialImage ( document . uri , webviewPanel . webview )
258254 }
259255 } )
260256 }
261257
258+ /**
259+ * Extensions the webview can route on its own (mirrors the customEditors
260+ * selector in package.json plus formats handled by dedicated code paths).
261+ * Files matching none of these get content-sniffed for DICOM, since DICOM
262+ * exports often have no extension or a bare UID as the file name.
263+ */
264+ private static readonly knownExtensions = [
265+ '.dcm' ,
266+ '.ima' ,
267+ '.nii' ,
268+ '.nii.gz' ,
269+ '.mih' ,
270+ '.mif' ,
271+ '.mif.gz' ,
272+ '.nhdr' ,
273+ '.nrrd' ,
274+ '.mhd' ,
275+ '.mha' ,
276+ '.mgh' ,
277+ '.mgz' ,
278+ '.v' ,
279+ '.v16' ,
280+ '.vmr' ,
281+ '.mz3' ,
282+ '.gii' ,
283+ '.mnc' ,
284+ '.mnc.gz' ,
285+ '.npy' ,
286+ '.npz' ,
287+ '.raw' ,
288+ ]
289+
290+ static hasKnownExtension ( lowerCasePath : string ) : boolean {
291+ return NiiVueEditorProvider . knownExtensions . some ( ( ext ) => lowerCasePath . endsWith ( ext ) )
292+ }
293+
294+ /** DICOM Part 10 magic: a 128-byte preamble followed by "DICM". */
295+ static isDicomData ( data : Uint8Array ) : boolean {
296+ return (
297+ data . byteLength >= 132 &&
298+ data [ 128 ] === 0x44 && // D
299+ data [ 129 ] === 0x49 && // I
300+ data [ 130 ] === 0x43 && // C
301+ data [ 131 ] === 0x4d // M
302+ )
303+ }
304+
262305 /**
263306 * Build the message body for an `addImage`/overlay payload. Prefers a
264307 * webview URI (so NiiVue can stream large volumes) but falls back to reading
@@ -267,6 +310,10 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
267310 * `localResourceRoots`, or formats that NiiVue can only ingest as bytes
268311 * (`.dcm`, `.mnc`).
269312 *
313+ * Files without a recognized extension are read and sniffed for the DICOM
314+ * magic bytes; matches are shipped as binary so the webview routes them
315+ * through the DICOM loader.
316+ *
270317 * MHD files are handled separately by `sendMhdMessage` because they need
271318 * the paired `.raw` voxel file resolved alongside the header.
272319 */
@@ -286,9 +333,138 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
286333 uri : uri . toString ( ) ,
287334 }
288335 }
336+ if ( ! NiiVueEditorProvider . hasKnownExtension ( lowerCasePath ) ) {
337+ const data = await vscode . workspace . fs . readFile ( uri )
338+ if ( NiiVueEditorProvider . isDicomData ( data ) ) {
339+ return {
340+ data : NiiVueEditorProvider . toArrayBuffer ( data ) ,
341+ uri : uri . toString ( ) ,
342+ }
343+ }
344+ }
289345 return { uri : webview . asWebviewUri ( uri ) . toString ( ) }
290346 }
291347
348+ /**
349+ * Send the initial image(s) for a freshly opened panel.
350+ *
351+ * - MHD resolves its detached `.raw` voxel file.
352+ * - A DICOM file (recognized by extension or by sniffing the file's
353+ * content) expands to every DICOM file in the same folder, so a single
354+ * clicked slice loads its whole series. dcm2niix groups the slices and
355+ * splits a multi-series folder into one volume per series.
356+ * - Everything else loads as a single image.
357+ */
358+ static async sendInitialImage ( uri : vscode . Uri , webview : vscode . Webview ) : Promise < void > {
359+ if ( uri . path . toLowerCase ( ) . endsWith ( '.mhd' ) ) {
360+ await NiiVueEditorProvider . sendMhdMessage ( uri , webview )
361+ return
362+ }
363+ const name = uri . path . split ( '/' ) . pop ( ) ?? ''
364+ if ( NiiVueEditorProvider . isDicomCandidateName ( name ) ) {
365+ try {
366+ const series = await NiiVueEditorProvider . collectDicomFolderImages ( uri )
367+ if ( series ) {
368+ webview . postMessage ( {
369+ type : 'addImage' ,
370+ body : { uri : series . uris , data : series . datas } ,
371+ } )
372+ return
373+ }
374+ } catch {
375+ // Fall through to a single-file load if directory scanning fails.
376+ }
377+ }
378+ const body = await NiiVueEditorProvider . uriToImageBody ( uri , webview )
379+ webview . postMessage ( { type : 'addImage' , body } )
380+ }
381+
382+ /**
383+ * Cheap name-based pre-filter for DICOM files, used to avoid reading every
384+ * sibling in a directory. Matches `.dcm`/`.ima`, extension-less names
385+ * (`IM_0001`), and DICOM UID-style names (digits and dots). Actual content
386+ * is still verified with `isDicomData` before a file is treated as DICOM.
387+ */
388+ static isDicomCandidateName ( name : string ) : boolean {
389+ const base = ( name . split ( '/' ) . pop ( ) ?? name ) . toLowerCase ( )
390+ if ( base . endsWith ( '.dcm' ) || base . endsWith ( '.ima' ) ) {
391+ return true
392+ }
393+ return ! base . includes ( '.' ) || / ^ [ \d . ] + $ / . test ( base )
394+ }
395+
396+ /** The parent directory of a file URI. */
397+ private static parentDir ( uri : vscode . Uri ) : vscode . Uri {
398+ const slashIndex = uri . path . lastIndexOf ( '/' )
399+ const parentPath = slashIndex >= 0 ? uri . path . substring ( 0 , slashIndex ) : ''
400+ return uri . with ( { path : parentPath || '/' } )
401+ }
402+
403+ /**
404+ * Read every DICOM file in a directory (name pre-filtered, content
405+ * verified) and return their URIs and bytes, sorted by URI for a stable
406+ * slice order.
407+ */
408+ static async collectDicomFolder (
409+ dirUri : vscode . Uri ,
410+ ) : Promise < { uris : string [ ] ; datas : ArrayBuffer [ ] } > {
411+ let entries : [ string , vscode . FileType ] [ ]
412+ try {
413+ entries = await vscode . workspace . fs . readDirectory ( dirUri )
414+ } catch {
415+ return { uris : [ ] , datas : [ ] }
416+ }
417+ const collected : { uri : string ; data : ArrayBuffer } [ ] = [ ]
418+ for ( const [ name , fileType ] of entries ) {
419+ if ( ( fileType & vscode . FileType . File ) === 0 ) {
420+ continue
421+ }
422+ if ( ! NiiVueEditorProvider . isDicomCandidateName ( name ) ) {
423+ continue
424+ }
425+ const fileUri = vscode . Uri . joinPath ( dirUri , name )
426+ let bytes : Uint8Array
427+ try {
428+ bytes = await vscode . workspace . fs . readFile ( fileUri )
429+ } catch {
430+ continue
431+ }
432+ if ( ! NiiVueEditorProvider . isDicomData ( bytes ) ) {
433+ continue
434+ }
435+ collected . push ( { uri : fileUri . toString ( ) , data : NiiVueEditorProvider . toArrayBuffer ( bytes ) } )
436+ }
437+ collected . sort ( ( a , b ) => ( a . uri < b . uri ? - 1 : a . uri > b . uri ? 1 : 0 ) )
438+ return { uris : collected . map ( ( c ) => c . uri ) , datas : collected . map ( ( c ) => c . data ) }
439+ }
440+
441+ /**
442+ * If `fileUri` is a DICOM file, return every DICOM file in its directory so
443+ * the whole series loads together. Returns null when the file is not DICOM,
444+ * so the caller falls back to a single-file load.
445+ */
446+ static async collectDicomFolderImages (
447+ fileUri : vscode . Uri ,
448+ ) : Promise < { uris : string [ ] ; datas : ArrayBuffer [ ] } | null > {
449+ let clicked : Uint8Array
450+ try {
451+ clicked = await vscode . workspace . fs . readFile ( fileUri )
452+ } catch {
453+ return null
454+ }
455+ if ( ! NiiVueEditorProvider . isDicomData ( clicked ) ) {
456+ return null
457+ }
458+ const series = await NiiVueEditorProvider . collectDicomFolder (
459+ NiiVueEditorProvider . parentDir ( fileUri ) ,
460+ )
461+ if ( series . uris . length === 0 ) {
462+ // Directory listing failed; load just the clicked file.
463+ return { uris : [ fileUri . toString ( ) ] , datas : [ NiiVueEditorProvider . toArrayBuffer ( clicked ) ] }
464+ }
465+ return series
466+ }
467+
292468 /**
293469 * Whether `uri` can be served through the webview resource proxy. Requires
294470 * the same scheme *and* authority as one of the workspace folders so that a
0 commit comments