11/** Client-side filename validation for instant keystroke feedback. */
22
3- const MAX_NAME_BYTES = 255
4- const MAX_PATH_BYTES = 1024
3+ /** Defaults match macOS. Call initPathLimits() at startup to get platform-specific values from the backend. */
4+ let maxNameBytes = 255
5+ let maxPathBytes = 1024
6+
7+ /** Fetches platform-specific limits from the backend. Call once at app startup. */
8+ export async function initPathLimits ( ) : Promise < void > {
9+ const { getPathLimits } = await import ( '$lib/tauri-commands' )
10+ const limits = await getPathLimits ( )
11+ maxNameBytes = limits . maxNameBytes
12+ maxPathBytes = limits . maxPathBytes
13+ }
514
615/** Characters disallowed on macOS (APFS/HFS+). TODO: Per-OS logic for future platforms. */
716const DISALLOWED_CHARS_REGEX = / [ / \0 ] /
@@ -15,29 +24,33 @@ export interface ValidationResult {
1524
1625const OK_RESULT : ValidationResult = { severity : 'ok' , message : '' }
1726
18- /** Validates a filename for disallowed characters. Operates on trimmed value. */
19- export function validateDisallowedChars ( name : string ) : ValidationResult {
27+ function nameLabel ( isDir : boolean ) : string {
28+ return isDir ? 'Folder name' : 'Filename'
29+ }
30+
31+ /** Validates a file or dir name for disallowed characters. Operates on trimmed value. */
32+ export function validateDisallowedChars ( name : string , isDir = false ) : ValidationResult {
2033 if ( DISALLOWED_CHARS_REGEX . test ( name ) ) {
21- return { severity : 'error' , message : 'Filenames can\ 't contain "/" or null characters' }
34+ return { severity : 'error' , message : ` ${ nameLabel ( isDir ) } can't contain "/" or null characters` }
2235 }
2336 return OK_RESULT
2437}
2538
26- /** Validates that a filename is not empty after trimming. */
27- export function validateNotEmpty ( name : string ) : ValidationResult {
39+ /** Validates that a file or dir name is not empty after trimming. */
40+ export function validateNotEmpty ( name : string , isDir = false ) : ValidationResult {
2841 if ( name . trim ( ) . length === 0 ) {
29- return { severity : 'error' , message : "A filename can't be empty" }
42+ return { severity : 'error' , message : ` ${ nameLabel ( isDir ) } can't be empty` }
3043 }
3144 return OK_RESULT
3245}
3346
34- /** Validates filename byte length (max 255 bytes). */
35- export function validateNameLength ( name : string ) : ValidationResult {
47+ /** Validates name byte length (max 255 bytes). */
48+ export function validateNameLength ( name : string , isDir = false ) : ValidationResult {
3649 const byteLength = new TextEncoder ( ) . encode ( name . trim ( ) ) . length
37- if ( byteLength >= MAX_NAME_BYTES ) {
50+ if ( byteLength >= maxNameBytes ) {
3851 return {
3952 severity : 'error' ,
40- message : `Filename is too long (${ String ( byteLength ) } /${ String ( MAX_NAME_BYTES ) } bytes)` ,
53+ message : `${ nameLabel ( isDir ) } is too long (${ String ( byteLength ) } /${ String ( maxNameBytes ) } bytes)` ,
4154 }
4255 }
4356 return OK_RESULT
@@ -47,10 +60,10 @@ export function validateNameLength(name: string): ValidationResult {
4760export function validatePathLength ( parentPath : string , name : string ) : ValidationResult {
4861 const fullPath = parentPath . endsWith ( '/' ) ? parentPath + name . trim ( ) : parentPath + '/' + name . trim ( )
4962 const byteLength = new TextEncoder ( ) . encode ( fullPath ) . length
50- if ( byteLength >= MAX_PATH_BYTES ) {
63+ if ( byteLength >= maxPathBytes ) {
5164 return {
5265 severity : 'error' ,
53- message : `Full path is too long (${ String ( byteLength ) } /${ String ( MAX_PATH_BYTES ) } bytes)` ,
66+ message : `Full path is too long (${ String ( byteLength ) } /${ String ( maxPathBytes ) } bytes)` ,
5467 }
5568 }
5669 return OK_RESULT
@@ -102,6 +115,45 @@ export function validateConflict(newName: string, siblingNames: string[], origin
102115 return OK_RESULT
103116}
104117
118+ /** Validates a full directory path (not a filename). Checks structure only, not existence. */
119+ export function validateDirectoryPath ( path : string ) : ValidationResult {
120+ const trimmed = path . trim ( )
121+
122+ if ( trimmed . length === 0 ) {
123+ return { severity : 'error' , message : "Path can't be empty" }
124+ }
125+
126+ if ( ! trimmed . startsWith ( '/' ) ) {
127+ return { severity : 'error' , message : 'Path must be absolute (start with /)' }
128+ }
129+
130+ if ( trimmed . includes ( '\0' ) ) {
131+ return { severity : 'error' , message : 'Path contains a null character' }
132+ }
133+
134+ const encoder = new TextEncoder ( )
135+ const totalBytes = encoder . encode ( trimmed ) . length
136+ if ( totalBytes >= maxPathBytes ) {
137+ return {
138+ severity : 'error' ,
139+ message : `Path is too long (${ String ( totalBytes ) } /${ String ( maxPathBytes ) } bytes)` ,
140+ }
141+ }
142+
143+ const segments = trimmed . split ( '/' ) . filter ( ( s ) => s . length > 0 )
144+ for ( const segment of segments ) {
145+ const segmentBytes = encoder . encode ( segment ) . length
146+ if ( segmentBytes >= maxNameBytes ) {
147+ return {
148+ severity : 'error' ,
149+ message : `A folder name in the path is too long (${ String ( segmentBytes ) } /${ String ( maxNameBytes ) } bytes)` ,
150+ }
151+ }
152+ }
153+
154+ return OK_RESULT
155+ }
156+
105157/** Runs all validation checks and returns the first error, then the first warning, or ok. */
106158export function validateFilename (
107159 newName : string ,
0 commit comments