88 FileTypeEnum ,
99 FsResource ,
1010} from "./types" ;
11+ import { fileIndex } from "./file-index" ;
1112
1213export class FsResourceCreationError extends Error {
1314 path : string ;
@@ -32,6 +33,7 @@ export function createFsResource<T extends FsResource["type"]>(params: {
3233 rootPath : string ;
3334 path : string ;
3435 type : T ;
36+ exposedWorkspacePaths ?: Map < string , unknown > ,
3537} ) : FsResource & { type : T } {
3638 try {
3739 const { rootPath, type } = params ;
@@ -47,12 +49,11 @@ export function createFsResource<T extends FsResource["type"]>(params: {
4749 ) ;
4850 }
4951
50- const normalizedRootPath = getNormalizedPath ( rootPath ) ;
51- const pathRootSlice = path . slice ( 0 , normalizedRootPath . length ) ;
52-
53- if ( normalizedRootPath !== pathRootSlice ) {
52+ const normalizedRootPaths = new Set < string > ( ) . add ( getNormalizedPath ( rootPath ) ) ;
53+ params . exposedWorkspacePaths ?. keys ( ) . forEach ( path => normalizedRootPaths . add ( getNormalizedPath ( path ) ) ) ;
54+ if ( ! normalizedRootPaths . values ( ) . some ( rootPath => path . startsWith ( rootPath ) ) ) {
5455 throw new Error (
55- `Can not create fs resource reference! Path '${ path } ' lies outside workspace path ' ${ rootPath } ' `
56+ `Can not create fs resource reference! Path '${ path } ' lies outside workspace paths! `
5657 ) ;
5758 }
5859
@@ -124,7 +125,8 @@ export function parseJsonContent<T extends TSchema>(
124125}
125126
126127export function getIdFromPath ( path : string ) {
127- return path ;
128+ const id = fileIndex . getId ( path ) ;
129+ return id ;
128130}
129131
130132export function mapSuccessWrite <
@@ -217,3 +219,156 @@ export function createFileSystemError(
217219 } ,
218220 } ;
219221}
222+
223+ /**
224+ * WARNING: Genrated by Claude
225+ *
226+ * Sanitizes a string to be safe for use as a filename or folder name across all platforms
227+ * (Windows, macOS, Linux)
228+ *
229+ * @param input - The input string to sanitize
230+ * @param maxLength - Maximum length for the filename (default: 100)
231+ * @param replacement - Character to replace invalid characters with (default: '_')
232+ * @returns A sanitized filename safe for all platforms
233+ */
234+ export function sanitizeFsResourceName (
235+ input : string ,
236+ maxLength : number = 100 ,
237+ replacement : string = '_'
238+ ) : string {
239+ if ( ! input || typeof input !== 'string' ) {
240+ return 'Untitled' ;
241+ }
242+
243+ // Trim whitespace
244+ let sanitized = input . trim ( ) ;
245+
246+ // If empty after trim, return default
247+ if ( ! sanitized ) {
248+ return 'Untitled' ;
249+ }
250+
251+ // Reserved names on Windows (case-insensitive)
252+ const reservedNames = new Set ( [
253+ 'CON' , 'PRN' , 'AUX' , 'NUL' ,
254+ 'COM1' , 'COM2' , 'COM3' , 'COM4' , 'COM5' , 'COM6' , 'COM7' , 'COM8' , 'COM9' ,
255+ 'LPT1' , 'LPT2' , 'LPT3' , 'LPT4' , 'LPT5' , 'LPT6' , 'LPT7' , 'LPT8' , 'LPT9'
256+ ] ) ;
257+
258+ // Characters that are invalid in filenames across platforms
259+ // Windows: < > : " | ? * \ /
260+ // macOS: : (converted to /)
261+ // Linux: / and null character
262+ // We'll be conservative and exclude all problematic characters
263+ const invalidCharsRegex = / [ < > : " / \\ | ? * \x00 - \x1F \x7F ] / g;
264+
265+ // Replace invalid characters
266+ sanitized = sanitized . replace ( invalidCharsRegex , replacement ) ;
267+
268+ // Remove or replace problematic characters at start/end
269+ // Can't start or end with spaces or dots on Windows
270+ sanitized = sanitized . replace ( / ^ [ . \s ] + | [ . \s ] + $ / g, replacement ) ;
271+
272+ // Handle multiple consecutive replacement characters
273+ if ( replacement ) {
274+ const replacementRegex = new RegExp ( `${ escapeRegex ( replacement ) } +` , 'g' ) ;
275+ sanitized = sanitized . replace ( replacementRegex , replacement ) ;
276+ }
277+
278+ // Check if it's a reserved name (Windows)
279+ const nameWithoutExt = sanitized . split ( '.' ) [ 0 ] . toUpperCase ( ) ;
280+ if ( reservedNames . has ( nameWithoutExt ) ) {
281+ sanitized = `${ replacement } ${ sanitized } ` ;
282+ }
283+
284+ // Ensure it doesn't start with a dash (can cause issues with command line tools)
285+ if ( sanitized . startsWith ( '-' ) ) {
286+ sanitized = replacement + sanitized . slice ( 1 ) ;
287+ }
288+
289+ // Limit length while preserving file extension if present
290+ if ( sanitized . length > maxLength ) {
291+ const lastDotIndex = sanitized . lastIndexOf ( '.' ) ;
292+ if ( lastDotIndex > 0 && lastDotIndex > sanitized . length - 10 ) {
293+ // Has extension, preserve it
294+ const extension = sanitized . slice ( lastDotIndex ) ;
295+ const nameOnly = sanitized . slice ( 0 , lastDotIndex ) ;
296+ const maxNameLength = maxLength - extension . length ;
297+ sanitized = nameOnly . slice ( 0 , maxNameLength ) + extension ;
298+ } else {
299+ // No extension or extension is too far back
300+ sanitized = sanitized . slice ( 0 , maxLength ) ;
301+ }
302+ }
303+
304+ // Final cleanup - remove trailing dots and spaces again (in case truncation caused issues)
305+ sanitized = sanitized . replace ( / [ . \s ] + $ / , '' ) ;
306+
307+ // If we ended up with an empty string, return default
308+ if ( ! sanitized ) {
309+ return 'Untitled' ;
310+ }
311+
312+ return sanitized ;
313+ }
314+
315+ /**
316+ * Helper function to escape special regex characters
317+ */
318+ function escapeRegex ( string : string ) : string {
319+ return string . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
320+ }
321+
322+ /**
323+ * Detects if a given name corresponds to a 'new' entity based on a base string pattern.
324+ * Matches exact base string or base string followed by a number.
325+ *
326+ * @param name - The name to check (e.g., 'Untitled', 'Untitled1', 'Untitled42')
327+ * @param baseString - The base string to match against (e.g., 'Untitled', 'New Environment')
328+ * @returns true if the name matches the pattern, false otherwise
329+ */
330+ export function isNewEntityName ( name : string , baseString : string ) : boolean {
331+ // Escape special regex characters in the base string
332+ const escapedBase = baseString . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
333+
334+ // Pattern: exact match OR base string followed by one or more digits
335+ const pattern = new RegExp ( `^${ escapedBase } (\\d+)?$` ) ;
336+
337+ return pattern . test ( name ) ;
338+ }
339+
340+ /**
341+ * Pure function that generates the next available name variant.
342+ * Always starts with baseName + "1" and increments until finding an available name.
343+ *
344+ * @param baseName - The base name to generate alternatives for
345+ * @param existingNames - Array of existing names to avoid conflicts with
346+ * @returns The next available name variant (e.g., 'Untitled1', 'Untitled2')
347+ */
348+ export function getAlternateName ( baseName : string , existingNames : Set < string > ) : string {
349+ if ( ! existingNames . has ( baseName ) ) {
350+ return baseName ;
351+ }
352+ let counter = 1 ;
353+ let candidateName = `${ baseName } ${ counter } ` ;
354+
355+ while ( existingNames . has ( candidateName ) ) {
356+ counter ++ ;
357+ candidateName = `${ baseName } ${ counter } ` ;
358+ }
359+
360+ return candidateName ;
361+ }
362+
363+ export function getNewNameIfQuickCreate ( params : {
364+ name : string ,
365+ baseName : string ,
366+ parentPath : string ,
367+ } ) {
368+ if ( ! isNewEntityName ( params . name , params . baseName ) ) {
369+ return params . name ;
370+ }
371+ const children = fileIndex . getImmediateChildren ( params . parentPath ) ;
372+
373+ return getAlternateName ( params . baseName , children ) ;
374+ }
0 commit comments