@@ -4,6 +4,9 @@ const fs = require('fs')
44const chokidar = require ( 'chokidar' )
55const { spawnSync } = require ( 'child_process' )
66
7+ const fileNameCollator = new Intl . Collator ( undefined , { numeric : true , sensitivity : 'base' } )
8+ const IS_WINDOWS = process . platform === 'win32'
9+
710let pty = null
811try {
912 pty = require ( 'node-pty' )
@@ -627,6 +630,71 @@ function getFileType(fileName) {
627630 return typeMap [ ext ] || 'text'
628631}
629632
633+ function detectTextMeta ( buffer ) {
634+ if ( ! buffer || buffer . length === 0 ) {
635+ return { encoding : 'utf8' , bom : 'none' , bomLength : 0 }
636+ }
637+ if ( buffer . length >= 3 && buffer [ 0 ] === 0xef && buffer [ 1 ] === 0xbb && buffer [ 2 ] === 0xbf ) {
638+ return { encoding : 'utf8' , bom : 'utf8' , bomLength : 3 }
639+ }
640+ if ( buffer . length >= 2 && buffer [ 0 ] === 0xff && buffer [ 1 ] === 0xfe ) {
641+ return { encoding : 'utf16le' , bom : 'utf16le' , bomLength : 2 }
642+ }
643+ if ( buffer . length >= 2 && buffer [ 0 ] === 0xfe && buffer [ 1 ] === 0xff ) {
644+ return { encoding : 'utf16be' , bom : 'utf16be' , bomLength : 2 }
645+ }
646+ return { encoding : 'utf8' , bom : 'none' , bomLength : 0 }
647+ }
648+
649+ function decodeTextBuffer ( buffer ) {
650+ const meta = detectTextMeta ( buffer )
651+ const body = buffer . slice ( meta . bomLength )
652+ if ( meta . encoding === 'utf16le' ) {
653+ return { text : body . toString ( 'utf16le' ) , meta }
654+ }
655+ if ( meta . encoding === 'utf16be' ) {
656+ const swapped = Buffer . from ( body )
657+ if ( swapped . length >= 2 ) swapped . swap16 ( )
658+ return { text : swapped . toString ( 'utf16le' ) , meta }
659+ }
660+ return { text : body . toString ( 'utf8' ) , meta }
661+ }
662+
663+ function detectEol ( text ) {
664+ if ( typeof text !== 'string' || text . length === 0 ) return '\n'
665+ return text . indexOf ( '\r\n' ) >= 0 ? '\r\n' : '\n'
666+ }
667+
668+ function normalizeEol ( text , eol ) {
669+ const normalized = String ( text || '' ) . replace ( / \r \n / g, '\n' ) . replace ( / \r / g, '\n' )
670+ if ( eol === '\r\n' ) return normalized . replace ( / \n / g, '\r\n' )
671+ return normalized
672+ }
673+
674+ function encodeTextBuffer ( text , options ) {
675+ const encoding = options ?. encoding || 'utf8'
676+ const bom = options ?. bom || 'none'
677+ const eol = options ?. eol || '\n'
678+ const normalizedText = normalizeEol ( text , eol )
679+
680+ if ( encoding === 'utf16le' ) {
681+ const body = Buffer . from ( normalizedText , 'utf16le' )
682+ if ( bom === 'utf16le' ) return Buffer . concat ( [ Buffer . from ( [ 0xff , 0xfe ] ) , body ] )
683+ return body
684+ }
685+
686+ if ( encoding === 'utf16be' ) {
687+ const body = Buffer . from ( normalizedText , 'utf16le' )
688+ if ( body . length >= 2 ) body . swap16 ( )
689+ if ( bom === 'utf16be' ) return Buffer . concat ( [ Buffer . from ( [ 0xfe , 0xff ] ) , body ] )
690+ return body
691+ }
692+
693+ const body = Buffer . from ( normalizedText , 'utf8' )
694+ if ( bom === 'utf8' ) return Buffer . concat ( [ Buffer . from ( [ 0xef , 0xbb , 0xbf ] ) , body ] )
695+ return body
696+ }
697+
630698// 读取目录结构 - 支持所有文件
631699function readDirectoryTree ( dirPath , basePath = '' , maxDepth = null , currentDepth = 0 ) {
632700 const items = [ ]
@@ -638,9 +706,18 @@ function readDirectoryTree(dirPath, basePath = '', maxDepth = null, currentDepth
638706
639707 try {
640708 const entries = fs . readdirSync ( dirPath , { withFileTypes : true } )
709+ . filter ( ( entry ) => {
710+ if ( entry . isDirectory ( ) && ( entry . name === 'node_modules' || entry . name === '.git' ) ) return false
711+ return true
712+ } )
713+ . sort ( ( a , b ) => {
714+ const aIsDir = a . isDirectory ( )
715+ const bIsDir = b . isDirectory ( )
716+ if ( aIsDir !== bIsDir ) return aIsDir ? - 1 : 1
717+ return fileNameCollator . compare ( a . name , b . name )
718+ } )
719+
641720 for ( const entry of entries ) {
642- // 跳过隐藏文件和 node_modules
643- if ( entry . name . startsWith ( '.' ) || entry . name === 'node_modules' ) continue
644721
645722 const fullPath = path . join ( dirPath , entry . name )
646723 const relativePath = basePath ? path . join ( basePath , entry . name ) : entry . name
@@ -717,6 +794,24 @@ ipcMain.handle('perf-log', async (event, payload) => {
717794 return { success : true }
718795} )
719796
797+ // 剪贴板(通过主进程读写,避免 preload 中 clipboard 不可用)
798+ ipcMain . on ( 'clipboard-write-text' , ( event , text ) => {
799+ try {
800+ clipboard . writeText ( String ( text || '' ) )
801+ } catch ( err ) {
802+ console . error ( '写入剪贴板失败:' , err . message )
803+ }
804+ } )
805+
806+ ipcMain . on ( 'clipboard-read-text' , ( event ) => {
807+ try {
808+ event . returnValue = clipboard . readText ( )
809+ } catch ( err ) {
810+ console . error ( '读取剪贴板失败:' , err . message )
811+ event . returnValue = ''
812+ }
813+ } )
814+
720815// 读取目录(限制深度)
721816ipcMain . handle ( 'read-directory-depth' , async ( event , payload ) => {
722817 const perfStart = perfLogEnabled ? Date . now ( ) : 0
@@ -798,8 +893,18 @@ ipcMain.handle('read-file', async (event, filePath) => {
798893 }
799894 }
800895
801- const content = fs . readFileSync ( filePath , 'utf-8' )
802- return { success : true , content, fileType }
896+ const raw = fs . readFileSync ( filePath )
897+ const decoded = decodeTextBuffer ( raw )
898+ const content = decoded . text
899+ const eol = detectEol ( content )
900+ return {
901+ success : true ,
902+ content,
903+ fileType,
904+ encoding : decoded . meta . encoding ,
905+ bom : decoded . meta . bom ,
906+ eol
907+ }
803908 } catch ( err ) {
804909 // 静默处理文件不存在错误
805910 if ( err . code === 'ENOENT' ) {
@@ -810,6 +915,67 @@ ipcMain.handle('read-file', async (event, filePath) => {
810915 }
811916} )
812917
918+ // 保存文件(尽量保留原始编码 / BOM / 换行风格)
919+ ipcMain . handle ( 'save-file' , async ( event , payload ) => {
920+ const filePath = payload ?. filePath
921+ const content = payload ?. content
922+ const options = payload ?. options
923+
924+ try {
925+ if ( ! filePath || typeof filePath !== 'string' ) {
926+ return { success : false , error : '路径参数无效' }
927+ }
928+ if ( typeof content !== 'string' ) {
929+ return { success : false , error : '内容参数无效' }
930+ }
931+
932+ let effectiveOptions = options
933+ if ( ! effectiveOptions || ! effectiveOptions . encoding || ! effectiveOptions . eol || ! effectiveOptions . bom ) {
934+ try {
935+ if ( fs . existsSync ( filePath ) ) {
936+ const raw = fs . readFileSync ( filePath )
937+ const decoded = decodeTextBuffer ( raw )
938+ effectiveOptions = {
939+ encoding : decoded . meta . encoding ,
940+ bom : decoded . meta . bom ,
941+ eol : detectEol ( decoded . text ) ,
942+ ...( effectiveOptions || { } )
943+ }
944+ }
945+ } catch {
946+ // ignore probe failures
947+ }
948+ }
949+
950+ const buffer = encodeTextBuffer ( content , effectiveOptions || { } )
951+ fs . writeFileSync ( filePath , buffer )
952+ return { success : true }
953+ } catch ( err ) {
954+ return { success : false , error : err . message }
955+ }
956+ } )
957+
958+ // 在系统资源管理器中打开/定位
959+ ipcMain . handle ( 'reveal-in-explorer' , async ( event , payload ) => {
960+ const targetPath = payload ?. path
961+ const isDirectory = ! ! payload ?. isDirectory
962+ if ( ! targetPath || typeof targetPath !== 'string' ) {
963+ return { success : false , error : '路径参数无效' }
964+ }
965+
966+ try {
967+ const normalized = path . normalize ( targetPath )
968+ if ( isDirectory ) {
969+ await shell . openPath ( normalized )
970+ } else {
971+ shell . showItemInFolder ( normalized )
972+ }
973+ return { success : true }
974+ } catch ( err ) {
975+ return { success : false , error : err . message }
976+ }
977+ } )
978+
813979// 监听目录变化
814980ipcMain . handle ( 'watch-directory' , async ( event , payload ) => {
815981 try {
@@ -864,9 +1030,17 @@ ipcMain.handle('unwatch-directory', async (event, key) => {
8641030} )
8651031
8661032// 终端相关 IPC - 主进程直接使用 node-pty
867- const DEFAULT_SHELL = 'powershell.exe'
868- const CMD_SHELL = process . env . ComSpec || 'cmd.exe'
869- const USE_CONPTY = true
1033+ function resolveDefaultUnixShell ( ) {
1034+ const fromEnv = typeof process . env . SHELL === 'string' ? process . env . SHELL . trim ( ) : ''
1035+ if ( fromEnv && fs . existsSync ( fromEnv ) ) return fromEnv
1036+ if ( fs . existsSync ( '/bin/zsh' ) ) return '/bin/zsh'
1037+ if ( fs . existsSync ( '/bin/bash' ) ) return '/bin/bash'
1038+ return '/bin/sh'
1039+ }
1040+
1041+ const DEFAULT_SHELL = IS_WINDOWS ? 'powershell.exe' : resolveDefaultUnixShell ( )
1042+ const CMD_SHELL = IS_WINDOWS ? ( process . env . ComSpec || 'cmd.exe' ) : null
1043+ const USE_CONPTY = IS_WINDOWS
8701044const PASTE_IMAGE_PREFIX = 'img-'
8711045const PASTE_IMAGE_EXT = '.png'
8721046const PASTE_IMAGE_DIR = '.terminal-paste'
@@ -948,6 +1122,7 @@ function ensurePasteDirectory(cwd) {
9481122}
9491123
9501124function hideFile ( filePath ) {
1125+ if ( ! IS_WINDOWS ) return
9511126 try {
9521127 spawnSync ( 'attrib' , [ '+h' , filePath ] , { windowsHide : true } )
9531128 } catch {
@@ -958,18 +1133,33 @@ function hideFile(filePath) {
9581133function normalizeShell ( shell ) {
9591134 if ( ! shell ) return DEFAULT_SHELL
9601135 const value = String ( shell ) . toLowerCase ( )
961- if ( value === 'cmd' || value === 'cmd.exe' ) return CMD_SHELL
962- if ( value === 'powershell' || value === 'powershell.exe' ) return DEFAULT_SHELL
963- return DEFAULT_SHELL
1136+
1137+ if ( IS_WINDOWS ) {
1138+ if ( value === 'cmd' || value === 'cmd.exe' ) return CMD_SHELL
1139+ if ( value === 'powershell' || value === 'powershell.exe' ) return DEFAULT_SHELL
1140+ return DEFAULT_SHELL
1141+ }
1142+
1143+ if ( value === 'zsh' ) return '/bin/zsh'
1144+ if ( value === 'bash' ) return '/bin/bash'
1145+ if ( value === 'sh' ) return '/bin/sh'
1146+
1147+ return shell
9641148}
9651149
9661150function resolveShellArgs ( shell ) {
9671151 const value = String ( shell || '' ) . toLowerCase ( )
968- if ( value === 'cmd' || value === 'cmd.exe' ) return [ '/D' , '/K' , 'chcp 65001 >nul' ]
969- if ( value === 'powershell' || value === 'powershell.exe' ) {
970- const init = '$OutputEncoding=[Text.UTF8Encoding]::UTF8; [Console]::InputEncoding=[Text.UTF8Encoding]::UTF8; [Console]::OutputEncoding=[Text.UTF8Encoding]::UTF8; chcp 65001 > $null'
971- return [ '-NoLogo' , '-NoExit' , '-Command' , init ]
1152+ if ( IS_WINDOWS ) {
1153+ if ( value === 'cmd' || value === 'cmd.exe' ) return [ '/D' , '/K' , 'chcp 65001 >nul' ]
1154+ if ( value === 'powershell' || value === 'powershell.exe' ) {
1155+ const init = '$OutputEncoding=[Text.UTF8Encoding]::UTF8; [Console]::InputEncoding=[Text.UTF8Encoding]::UTF8; [Console]::OutputEncoding=[Text.UTF8Encoding]::UTF8; chcp 65001 > $null'
1156+ return [ '-NoLogo' , '-NoExit' , '-Command' , init ]
1157+ }
1158+ return [ ]
9721159 }
1160+
1161+ if ( value . endsWith ( 'zsh' ) ) return [ '-l' ]
1162+ if ( value . endsWith ( 'bash' ) ) return [ '--login' ]
9731163 return [ ]
9741164}
9751165
@@ -1038,14 +1228,17 @@ function startTerminal(options = {}) {
10381228 useConpty : USE_CONPTY ,
10391229 sessionId
10401230 } )
1041- const spawned = pty . spawn ( resolvedShell , shellArgs , {
1231+ const ptyOptions = {
10421232 name : 'xterm-256color' ,
10431233 cols : 80 ,
10441234 rows : 24 ,
10451235 cwd : resolvedCwd ,
1046- env : process . env ,
1047- useConpty : USE_CONPTY
1048- } )
1236+ env : process . env
1237+ }
1238+ if ( IS_WINDOWS ) {
1239+ ptyOptions . useConpty = USE_CONPTY
1240+ }
1241+ const spawned = pty . spawn ( resolvedShell , shellArgs , ptyOptions )
10491242 ptySessions . set ( sessionId , {
10501243 id : sessionId ,
10511244 pty : spawned ,
0 commit comments