11package cli
22
33import (
4+ "bytes"
45 "context"
56 "encoding/json"
67 "errors"
@@ -13,13 +14,15 @@ import (
1314 "sync"
1415 "sync/atomic"
1516 "time"
17+ "unicode/utf8"
1618
1719 "neo-code/internal/app"
1820 "neo-code/internal/checkpoint"
1921 "neo-code/internal/config"
2022 configstate "neo-code/internal/config/state"
2123 "neo-code/internal/gateway"
2224 providertypes "neo-code/internal/provider/types"
25+ "neo-code/internal/repository"
2326 agentruntime "neo-code/internal/runtime"
2427 agentsession "neo-code/internal/session"
2528 "neo-code/internal/skills"
@@ -31,6 +34,7 @@ const bridgeRuntimeUnavailableErrMsg = "gateway runtime bridge: runtime is unava
3134const bridgeAskSessionPrefix = "ask"
3235const bridgeAskRunPrefix = "ask-run"
3336const bridgeAskRunTTL = 30 * time .Minute
37+ const readFilePreviewLimitBytes int64 = 512 * 1024
3438
3539type runtimeRunCanceler interface {
3640 CancelRun (runID string ) bool
@@ -93,15 +97,11 @@ func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gatewa
9397 _ = bundle .Close ()
9498 return nil , nil , err
9599 }
96- defaultHash := agentsession .HashWorkspaceRoot (defaultWorkspaceRoot )
97- if _ , err := index .Register (defaultWorkspaceRoot , "" ); err != nil {
98- _ = bundle .Close ()
99- return nil , nil , err
100- }
101- if err := index .Save (); err != nil {
100+ if err := sanitizeWorkspaceIndex (index , defaultWorkspaceRoot ); err != nil {
102101 _ = bundle .Close ()
103102 return nil , nil , err
104103 }
104+ defaultHash := agentsession .HashWorkspaceRoot (defaultWorkspaceRoot )
105105
106106 bridge , err := newGatewayRuntimePortBridge (ctx , bundle .Runtime , bundle .SessionStore , bundle .ConfigManager , bundle .ProviderSelection , bundle .ToolRegistry )
107107 if err != nil {
@@ -156,6 +156,40 @@ func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gatewa
156156 return mw , mw .Close , nil
157157}
158158
159+ // sanitizeWorkspaceIndex 在 Gateway 启动时清洗工作区索引,移除失效或临时残留目录。
160+ func sanitizeWorkspaceIndex (index * agentsession.WorkspaceIndex , defaultWorkspaceRoot string ) error {
161+ if index == nil {
162+ return fmt .Errorf ("gateway runtime bridge: workspace index is nil" )
163+ }
164+
165+ shouldPersistDefault := agentsession .IsPersistentWorkspaceRoot (defaultWorkspaceRoot )
166+ changed := false
167+ if shouldPersistDefault {
168+ if _ , err := index .Register (defaultWorkspaceRoot , "" ); err != nil {
169+ return err
170+ }
171+ changed = true
172+ }
173+
174+ removed := index .Prune (func (record agentsession.WorkspaceRecord ) bool {
175+ path := strings .TrimSpace (record .Path )
176+ if path == "" {
177+ return true
178+ }
179+ if shouldPersistDefault && agentsession .WorkspacePathKey (path ) == agentsession .WorkspacePathKey (defaultWorkspaceRoot ) {
180+ return false
181+ }
182+ return ! agentsession .IsPersistentWorkspaceRoot (path )
183+ })
184+ if len (removed ) > 0 {
185+ changed = true
186+ }
187+ if ! changed {
188+ return nil
189+ }
190+ return index .Save ()
191+ }
192+
159193// resolveGatewayDefaultWorkspaceRoot 解析网关默认工作区,优先使用显式参数,缺失时回退到配置快照。
160194func resolveGatewayDefaultWorkspaceRoot (requestedWorkdir string , configWorkdir string ) (string , error ) {
161195 candidate := strings .TrimSpace (requestedWorkdir )
@@ -194,6 +228,7 @@ type gatewayRuntimePortBridge struct {
194228 sessionStore bridgeSessionStore
195229 configManager configManagerPort
196230 providerSelection providerSelectorPort
231+ repoService * repository.Service
197232 toolRegistry * tools.Registry
198233 events chan gateway.RuntimeEvent
199234
@@ -220,23 +255,30 @@ func newGatewayRuntimePortBridge(
220255 }
221256 var cm configManagerPort
222257 var ps providerSelectorPort
258+ var repoSvc * repository.Service
223259 var tr * tools.Registry
224260 for _ , extra := range extras {
225261 switch typed := extra .(type ) {
226262 case configManagerPort :
227263 cm = typed
228264 case providerSelectorPort :
229265 ps = typed
266+ case * repository.Service :
267+ repoSvc = typed
230268 case * tools.Registry :
231269 tr = typed
232270 }
233271 }
272+ if repoSvc == nil {
273+ repoSvc = repository .NewService ()
274+ }
234275
235276 bridge := & gatewayRuntimePortBridge {
236277 runtime : runtimeSvc ,
237278 sessionStore : store ,
238279 configManager : cm ,
239280 providerSelection : ps ,
281+ repoService : repoSvc ,
240282 toolRegistry : tr ,
241283 events : make (chan gateway.RuntimeEvent , 128 ),
242284 stopCh : make (chan struct {}),
@@ -672,6 +714,142 @@ func (b *gatewayRuntimePortBridge) ListFiles(ctx context.Context, input gateway.
672714 return result , nil
673715}
674716
717+ // ReadFile 读取工作目录内文件的只读预览内容。
718+ func (b * gatewayRuntimePortBridge ) ReadFile (ctx context.Context , input gateway.ReadFileInput ) (gateway.ReadFileResult , error ) {
719+ if err := b .ensureRuntimeAccess (input .SubjectID ); err != nil {
720+ return gateway.ReadFileResult {}, err
721+ }
722+ root , err := b .resolveListFilesRoot (ctx , gateway.ListFilesInput {
723+ SubjectID : input .SubjectID ,
724+ SessionID : input .SessionID ,
725+ Workdir : input .Workdir ,
726+ })
727+ if err != nil {
728+ return gateway.ReadFileResult {}, err
729+ }
730+ target , relativePath , err := resolveSafeListFilesPath (root , input .Path )
731+ if err != nil {
732+ return gateway.ReadFileResult {}, err
733+ }
734+ info , err := os .Stat (target )
735+ if err != nil {
736+ return gateway.ReadFileResult {}, err
737+ }
738+ if info .IsDir () {
739+ return gateway.ReadFileResult {}, fmt .Errorf ("gateway runtime bridge: readFile path is a directory" )
740+ }
741+
742+ result := gateway.ReadFileResult {
743+ Path : filepath .ToSlash (relativePath ),
744+ Encoding : "utf-8" ,
745+ Size : info .Size (),
746+ ModTime : info .ModTime ().UTC ().Format (time .RFC3339 ),
747+ }
748+ if info .Size () > readFilePreviewLimitBytes {
749+ result .Truncated = true
750+ return result , nil
751+ }
752+
753+ data , err := os .ReadFile (target )
754+ if err != nil {
755+ return gateway.ReadFileResult {}, err
756+ }
757+ if isBinaryPreviewContent (data ) {
758+ result .Encoding = "binary"
759+ result .IsBinary = true
760+ return result , nil
761+ }
762+ result .Content = string (data )
763+ return result , nil
764+ }
765+
766+ // ListGitDiffFiles 返回当前工作树相对 HEAD 的 Git 变更文件列表。
767+ func (b * gatewayRuntimePortBridge ) ListGitDiffFiles (ctx context.Context , input gateway.ListGitDiffFilesInput ) (gateway.ListGitDiffFilesResult , error ) {
768+ if err := b .ensureRuntimeAccess (input .SubjectID ); err != nil {
769+ return gateway.ListGitDiffFilesResult {}, err
770+ }
771+ root , err := b .resolveListFilesRoot (ctx , gateway.ListFilesInput {
772+ SubjectID : input .SubjectID ,
773+ SessionID : input .SessionID ,
774+ Workdir : input .Workdir ,
775+ })
776+ if err != nil {
777+ return gateway.ListGitDiffFilesResult {}, err
778+ }
779+ if b .repoService == nil {
780+ return gateway.ListGitDiffFilesResult {}, fmt .Errorf ("gateway runtime bridge: repository service is unavailable" )
781+ }
782+ inspection , err := b .repoService .Inspect (ctx , root , repository.InspectOptions {ChangedFilesLimit : 200 })
783+ if err != nil {
784+ return gateway.ListGitDiffFilesResult {}, err
785+ }
786+ result := gateway.ListGitDiffFilesResult {
787+ InGitRepo : inspection .Summary .InGitRepo ,
788+ Branch : inspection .Summary .Branch ,
789+ Ahead : inspection .Summary .Ahead ,
790+ Behind : inspection .Summary .Behind ,
791+ Files : make ([]gateway.GitDiffEntry , 0 , len (inspection .ChangedFiles .Files )),
792+ }
793+ if ! inspection .Summary .InGitRepo {
794+ return result , nil
795+ }
796+ result .Truncated = inspection .ChangedFiles .Truncated
797+ result .TotalCount = inspection .ChangedFiles .TotalCount
798+ for _ , file := range inspection .ChangedFiles .Files {
799+ result .Files = append (result .Files , gateway.GitDiffEntry {
800+ Path : filepath .ToSlash (file .Path ),
801+ OldPath : filepath .ToSlash (file .OldPath ),
802+ Status : string (file .Status ),
803+ })
804+ }
805+ return result , nil
806+ }
807+
808+ // ReadGitDiffFile 读取单个 Git 变更文件的双文本预览内容。
809+ func (b * gatewayRuntimePortBridge ) ReadGitDiffFile (ctx context.Context , input gateway.ReadGitDiffFileInput ) (gateway.ReadGitDiffFileResult , error ) {
810+ if err := b .ensureRuntimeAccess (input .SubjectID ); err != nil {
811+ return gateway.ReadGitDiffFileResult {}, err
812+ }
813+ root , err := b .resolveListFilesRoot (ctx , gateway.ListFilesInput {
814+ SubjectID : input .SubjectID ,
815+ SessionID : input .SessionID ,
816+ Workdir : input .Workdir ,
817+ })
818+ if err != nil {
819+ return gateway.ReadGitDiffFileResult {}, err
820+ }
821+ if b .repoService == nil {
822+ return gateway.ReadGitDiffFileResult {}, fmt .Errorf ("gateway runtime bridge: repository service is unavailable" )
823+ }
824+ result , err := b .repoService .ReadGitDiffFile (ctx , root , input .Path , readFilePreviewLimitBytes )
825+ if err != nil {
826+ return gateway.ReadGitDiffFileResult {}, err
827+ }
828+ return gateway.ReadGitDiffFileResult {
829+ Path : filepath .ToSlash (result .Path ),
830+ OldPath : filepath .ToSlash (result .OldPath ),
831+ Status : string (result .Status ),
832+ OriginalContent : result .OriginalContent ,
833+ ModifiedContent : result .ModifiedContent ,
834+ Encoding : result .Encoding ,
835+ IsBinary : result .IsBinary ,
836+ Truncated : result .Truncated ,
837+ OriginalSize : result .OriginalSize ,
838+ ModifiedSize : result .ModifiedSize ,
839+ }, nil
840+ }
841+
842+ // isBinaryPreviewContent 用启发式规则判断预览内容是否应视为二进制。
843+ func isBinaryPreviewContent (data []byte ) bool {
844+ if len (data ) == 0 {
845+ return false
846+ }
847+ if bytes .IndexByte (data , 0 ) >= 0 {
848+ return true
849+ }
850+ return ! utf8 .Valid (data )
851+ }
852+
675853// ListModels 列出可用模型;有会话时按会话有效 provider 返回,无会话时按全局默认 provider 返回。
676854func (b * gatewayRuntimePortBridge ) ListModels (ctx context.Context , input gateway.ListModelsInput ) ([]gateway.ModelEntry , error ) {
677855 if err := b .ensureRuntimeAccess (input .SubjectID ); err != nil {
@@ -862,6 +1040,9 @@ func (b *gatewayRuntimePortBridge) SelectProviderModel(ctx context.Context, inpu
8621040 return gateway.ProviderSelectionResult {}, err
8631041 }
8641042 }
1043+ if err := b .SyncSessionsProviderModel (ctx , selection .ProviderID , selection .ModelID ); err != nil {
1044+ return gateway.ProviderSelectionResult {}, err
1045+ }
8651046 return gateway.ProviderSelectionResult {ProviderID : selection .ProviderID , ModelID : selection .ModelID }, nil
8661047}
8671048
@@ -1772,6 +1953,52 @@ func (b *gatewayRuntimePortBridge) loadStoredSession(ctx context.Context, sessio
17721953 return loader .LoadSession (ctx , strings .TrimSpace (sessionID ))
17731954}
17741955
1956+ // SyncSessionsProviderModel 将当前工作区已列出的会话统一切换到新的 provider/model,避免全局切换后会话元数据继续滞留旧值。
1957+ func (b * gatewayRuntimePortBridge ) SyncSessionsProviderModel (
1958+ ctx context.Context ,
1959+ providerID string ,
1960+ modelID string ,
1961+ ) error {
1962+ if b == nil || b .sessionStore == nil || b .runtime == nil {
1963+ return nil
1964+ }
1965+ providerID = strings .TrimSpace (providerID )
1966+ modelID = strings .TrimSpace (modelID )
1967+ if providerID == "" || modelID == "" {
1968+ return nil
1969+ }
1970+
1971+ summaries , err := b .runtime .ListSessions (ctx )
1972+ if err != nil {
1973+ return err
1974+ }
1975+ for _ , summary := range summaries {
1976+ sessionID := strings .TrimSpace (summary .ID )
1977+ if sessionID == "" {
1978+ continue
1979+ }
1980+ session , loadErr := b .loadStoredSession (ctx , sessionID )
1981+ if loadErr != nil {
1982+ if errors .Is (loadErr , agentsession .ErrSessionNotFound ) {
1983+ continue
1984+ }
1985+ return loadErr
1986+ }
1987+ head := session .HeadSnapshot ()
1988+ head .Provider = providerID
1989+ head .Model = modelID
1990+ if updateErr := b .sessionStore .UpdateSessionState (ctx , agentsession.UpdateSessionStateInput {
1991+ SessionID : session .ID ,
1992+ Title : session .Title ,
1993+ UpdatedAt : time .Now ().UTC (),
1994+ Head : head ,
1995+ }); updateErr != nil {
1996+ return updateErr
1997+ }
1998+ }
1999+ return nil
2000+ }
2001+
17752002// resolveSafeListFilesPath 将前端传入的相对路径限制在根目录内。
17762003func resolveSafeListFilesPath (root string , rawPath string ) (string , string , error ) {
17772004 rootAbs , err := filepath .Abs (filepath .Clean (root ))
0 commit comments