Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

402 lines
13 KiB
Go

package workspacefiles
import (
"context"
"fmt"
"strings"
)
type Service struct {
Resolver WorkspaceResolver
Adapter FileAdapter
}
func (s Service) ListDirectory(ctx context.Context, workspaceID string, input DirectoryListInput) (DirectoryListing, error) {
root, logicalPath, err := s.resolvePath(ctx, workspaceID, input.Path)
if err != nil {
return DirectoryListing{}, err
}
listing, err := s.adapter().ListDirectory(ctx, root, logicalPath, input.IncludeHidden)
if err != nil {
return DirectoryListing{}, err
}
return normalizeDirectoryListing(root, logicalPath, listing), nil
}
func (s Service) CreateFile(ctx context.Context, workspaceID string, value string) (FileEntry, error) {
root, logicalPath, err := s.resolvePath(ctx, workspaceID, value)
if err != nil {
return FileEntry{}, err
}
entry, err := s.adapter().CreateFile(ctx, root, logicalPath)
if err != nil {
return FileEntry{}, err
}
return normalizeFileEntry(root, logicalPath, entry), nil
}
func (s Service) ReadFile(ctx context.Context, workspaceID string, value string, maxBytes int64) (FileContent, error) {
root, logicalPath, err := s.resolvePath(ctx, workspaceID, value)
if err != nil {
return FileContent{}, err
}
if maxBytes <= 0 {
maxBytes = DefaultReadFileMaxBytes
}
content, err := s.adapter().ReadFile(ctx, root, logicalPath, maxBytes)
if err != nil {
return FileContent{}, err
}
content.Path = logicalPath
if content.Name == "" {
content.Name = LogicalPathBase(logicalPath)
}
content.SizeBytes = int64(len(content.Bytes))
return content, nil
}
func (s Service) WriteTextFile(ctx context.Context, workspaceID string, value string, content string) (FileEntry, error) {
root, logicalPath, err := s.resolvePath(ctx, workspaceID, value)
if err != nil {
return FileEntry{}, err
}
entry, err := s.adapter().WriteTextFile(ctx, root, logicalPath, content)
if err != nil {
return FileEntry{}, err
}
return normalizeFileEntry(root, logicalPath, entry), nil
}
func (s Service) CreateDirectory(ctx context.Context, workspaceID string, value string) (FileEntry, error) {
root, logicalPath, err := s.resolvePath(ctx, workspaceID, value)
if err != nil {
return FileEntry{}, err
}
entry, err := s.adapter().CreateDirectory(ctx, root, logicalPath)
if err != nil {
return FileEntry{}, err
}
return normalizeFileEntry(root, logicalPath, entry), nil
}
func (s Service) DeleteEntry(ctx context.Context, workspaceID string, value string, kind EntryKind) error {
root, logicalPath, err := s.resolvePath(ctx, workspaceID, value)
if err != nil {
return err
}
if IsLogicalRoot(logicalPath, root.LogicalRoot) {
return ErrRootDeleteForbidden
}
if kind != "" && kind != EntryKindFile && kind != EntryKindDirectory && kind != EntryKindUnknown {
return fmt.Errorf("%w: %q", ErrInvalidEntryKind, kind)
}
return s.adapter().DeleteEntry(ctx, root, logicalPath, kind)
}
func (s Service) MoveEntry(ctx context.Context, workspaceID string, value string, targetDirectoryValue string) (FileEntry, error) {
defaultRoot, err := s.resolve(ctx, workspaceID)
if err != nil {
return FileEntry{}, err
}
root, err := s.resolveRootForPathsFromDefault(ctx, workspaceID, defaultRoot, value, targetDirectoryValue)
if err != nil {
return FileEntry{}, err
}
if defaultLogicalPath, err := NormalizeLogicalPathWithinRoot(value, defaultRoot.LogicalRoot); err == nil &&
IsLogicalRoot(defaultLogicalPath, defaultRoot.LogicalRoot) {
return FileEntry{}, ErrRootDeleteForbidden
}
logicalPath, err := NormalizeLogicalPathWithinRoot(pathValueForRoot(value, defaultRoot, root), root.LogicalRoot)
if err != nil {
return FileEntry{}, err
}
if IsLogicalRoot(logicalPath, root.LogicalRoot) {
return FileEntry{}, ErrRootDeleteForbidden
}
targetDirectoryPath, err := NormalizeLogicalPathWithinRoot(pathValueForRoot(targetDirectoryValue, defaultRoot, root), root.LogicalRoot)
if err != nil {
return FileEntry{}, err
}
if logicalPath == targetDirectoryPath || strings.HasPrefix(targetDirectoryPath.String(), logicalPath.String()+"/") {
return FileEntry{}, fmt.Errorf("%w: cannot move entry into itself", ErrInvalidPath)
}
entry, err := s.adapter().MoveEntry(ctx, root, logicalPath, targetDirectoryPath)
if err != nil {
return FileEntry{}, err
}
return normalizeFileEntry(root, entry.Path, entry), nil
}
func (s Service) RenameEntry(ctx context.Context, workspaceID string, value string, newName string) (FileEntry, error) {
root, logicalPath, err := s.resolvePath(ctx, workspaceID, value)
if err != nil {
return FileEntry{}, err
}
if IsLogicalRoot(logicalPath, root.LogicalRoot) {
return FileEntry{}, ErrRootDeleteForbidden
}
newName = strings.TrimSpace(newName)
if newName == "" || strings.Contains(newName, "/") || strings.Contains(newName, "\\") || newName == "." || newName == ".." {
return FileEntry{}, fmt.Errorf("%w: invalid entry name %q", ErrInvalidPath, newName)
}
renamedEntry, err := s.adapter().RenameEntry(ctx, root, logicalPath, newName)
if err != nil {
return FileEntry{}, err
}
return normalizeFileEntry(root, renamedEntry.Path, renamedEntry), nil
}
func (s Service) CopyEntry(ctx context.Context, workspaceID string, value string) (FileEntry, error) {
root, logicalPath, err := s.resolvePath(ctx, workspaceID, value)
if err != nil {
return FileEntry{}, err
}
if IsLogicalRoot(logicalPath, root.LogicalRoot) {
return FileEntry{}, ErrRootDeleteForbidden
}
copiedEntry, err := s.adapter().CopyEntry(ctx, root, logicalPath)
if err != nil {
return FileEntry{}, err
}
return normalizeFileEntry(root, copiedEntry.Path, copiedEntry), nil
}
func (s Service) UploadFiles(ctx context.Context, workspaceID string, input UploadInput) (UploadResult, error) {
root, targetDirectoryPath, err := s.resolvePath(ctx, workspaceID, input.TargetDirectoryPath)
if err != nil {
return UploadResult{}, err
}
sourcePaths := make([]string, 0, len(input.SourcePaths))
for _, sourcePath := range input.SourcePaths {
trimmed := strings.TrimSpace(sourcePath)
if trimmed == "" {
continue
}
sourcePaths = append(sourcePaths, trimmed)
}
if len(sourcePaths) == 0 {
return UploadResult{}, fmt.Errorf("%w: source paths are required", ErrInvalidUploadSource)
}
entries, err := s.adapter().UploadFiles(ctx, root, targetDirectoryPath, sourcePaths, input.Overwrite)
if err != nil {
return UploadResult{}, err
}
if entries == nil {
entries = []FileEntry{}
}
return UploadResult{
WorkspaceID: root.WorkspaceID,
Root: NormalizeLogicalRoot(root.LogicalRoot),
TargetDirectoryPath: targetDirectoryPath,
Entries: entries,
}, nil
}
func (s Service) PreflightUploadFiles(ctx context.Context, workspaceID string, input PreflightUploadInput) (PreflightUploadResult, error) {
root, targetDirectoryPath, err := s.resolvePath(ctx, workspaceID, input.TargetDirectoryPath)
if err != nil {
return PreflightUploadResult{}, err
}
sourcePaths := make([]string, 0, len(input.SourcePaths))
for _, sourcePath := range input.SourcePaths {
trimmed := strings.TrimSpace(sourcePath)
if trimmed == "" {
continue
}
sourcePaths = append(sourcePaths, trimmed)
}
if len(sourcePaths) == 0 {
return PreflightUploadResult{}, fmt.Errorf("%w: source paths are required", ErrInvalidUploadSource)
}
conflicts, err := s.adapter().PreflightUploadFiles(ctx, root, targetDirectoryPath, sourcePaths)
if err != nil {
return PreflightUploadResult{}, err
}
if conflicts == nil {
conflicts = []UploadConflict{}
}
return PreflightUploadResult{
WorkspaceID: root.WorkspaceID,
Root: NormalizeLogicalRoot(root.LogicalRoot),
TargetDirectoryPath: targetDirectoryPath,
Conflicts: conflicts,
}, nil
}
func (s Service) Search(ctx context.Context, workspaceID string, input SearchInput) (SearchResult, error) {
root, err := s.resolveRootForPaths(ctx, workspaceID, input.Within)
if err != nil {
return SearchResult{}, err
}
input.Query = strings.TrimSpace(input.Query)
input.Limit = NormalizeSearchLimit(input.Limit)
input.IncludeKinds, err = NormalizeSearchKinds(input.IncludeKinds)
if err != nil {
return SearchResult{}, err
}
input.Filters = NormalizeSearchFilters(input.Filters)
// 筛选与搜索是同一能力:关键词与筛选同时为空才算空查询。仅选了类型筛选(query 空)时
// 继续走 adapter,由其按类型 list-all。
if input.Query == "" && len(input.Filters) == 0 {
return SearchResult{
WorkspaceID: root.WorkspaceID,
Root: NormalizeLogicalRoot(root.LogicalRoot),
Entries: []SearchEntry{},
}, nil
}
result, err := s.adapter().Search(ctx, root, input)
if err != nil {
return SearchResult{}, err
}
result.WorkspaceID = root.WorkspaceID
result.Root = NormalizeLogicalRoot(root.LogicalRoot)
if result.Entries == nil {
result.Entries = []SearchEntry{}
}
return result, nil
}
// ListRecent returns the workspace's recently accessed entries, most-recent
// first. When the adapter does not implement RecentFilesLister the result is an
// empty listing rooted at the workspace root.
func (s Service) ListRecent(ctx context.Context, workspaceID string, input RecentListInput) (DirectoryListing, error) {
root, err := s.resolve(ctx, workspaceID)
if err != nil {
return DirectoryListing{}, err
}
normalizedRoot := NormalizeLogicalRoot(root.LogicalRoot)
empty := DirectoryListing{
WorkspaceID: root.WorkspaceID,
Root: normalizedRoot,
DirectoryPath: normalizedRoot,
Entries: []FileEntry{},
}
lister, ok := s.adapter().(RecentFilesLister)
if !ok {
return empty, nil
}
listing, err := lister.ListRecent(ctx, root, NormalizeRecentLimit(input.Limit))
if err != nil {
return DirectoryListing{}, err
}
listing.WorkspaceID = root.WorkspaceID
listing.Root = normalizedRoot
if listing.DirectoryPath == "" {
listing.DirectoryPath = normalizedRoot
}
if listing.Entries == nil {
listing.Entries = []FileEntry{}
}
return listing, nil
}
func (s Service) resolvePath(ctx context.Context, workspaceID string, value string) (WorkspaceRoot, LogicalPath, error) {
root, err := s.resolveRootForPaths(ctx, workspaceID, value)
if err != nil {
return WorkspaceRoot{}, "", err
}
logicalPath, err := NormalizeLogicalPathWithinRoot(value, root.LogicalRoot)
if err != nil {
return WorkspaceRoot{}, "", err
}
return root, logicalPath, nil
}
func (s Service) resolveRootForPaths(ctx context.Context, workspaceID string, values ...string) (WorkspaceRoot, error) {
root, err := s.resolve(ctx, workspaceID)
if err != nil {
return WorkspaceRoot{}, err
}
return s.resolveRootForPathsFromDefault(ctx, workspaceID, root, values...)
}
func (s Service) resolveRootForPathsFromDefault(ctx context.Context, workspaceID string, root WorkspaceRoot, values ...string) (WorkspaceRoot, error) {
pathResolver, ok := s.Resolver.(PathAwareWorkspaceResolver)
if !ok {
return root, nil
}
for _, value := range values {
pathRoot, err := pathResolver.ResolveWorkspaceRootForPath(ctx, workspaceID, value)
if err != nil {
return WorkspaceRoot{}, err
}
pathRoot = normalizeResolvedRoot(pathRoot, workspaceID)
if pathRoot.LogicalRoot != root.LogicalRoot || strings.TrimSpace(pathRoot.PhysicalRoot) != strings.TrimSpace(root.PhysicalRoot) {
return pathRoot, nil
}
}
return root, nil
}
func pathValueForRoot(value string, defaultRoot WorkspaceRoot, root WorkspaceRoot) string {
raw := strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
if raw == "" || strings.HasPrefix(raw, "/") || sameWorkspaceRoot(defaultRoot, root) {
return value
}
defaultLogicalPath, err := NormalizeLogicalPathWithinRoot(value, defaultRoot.LogicalRoot)
if err != nil {
return value
}
return defaultLogicalPath.String()
}
func sameWorkspaceRoot(left WorkspaceRoot, right WorkspaceRoot) bool {
return NormalizeLogicalRoot(left.LogicalRoot) == NormalizeLogicalRoot(right.LogicalRoot) &&
strings.TrimSpace(left.PhysicalRoot) == strings.TrimSpace(right.PhysicalRoot)
}
func (s Service) resolve(ctx context.Context, workspaceID string) (WorkspaceRoot, error) {
if s.Resolver == nil {
return WorkspaceRoot{}, ErrResolverNotConfigured
}
if s.Adapter == nil {
return WorkspaceRoot{}, ErrAdapterNotConfigured
}
root, err := s.Resolver.ResolveWorkspaceRoot(ctx, workspaceID)
if err != nil {
return WorkspaceRoot{}, err
}
return normalizeResolvedRoot(root, workspaceID), nil
}
func (s Service) adapter() FileAdapter {
return s.Adapter
}
func normalizeResolvedRoot(root WorkspaceRoot, workspaceID string) WorkspaceRoot {
root.WorkspaceID = strings.TrimSpace(root.WorkspaceID)
if root.WorkspaceID == "" {
root.WorkspaceID = strings.TrimSpace(workspaceID)
}
root.LogicalRoot = NormalizeLogicalRoot(root.LogicalRoot).String()
return root
}
func normalizeDirectoryListing(root WorkspaceRoot, requested LogicalPath, listing DirectoryListing) DirectoryListing {
listing.WorkspaceID = root.WorkspaceID
listing.Root = NormalizeLogicalRoot(root.LogicalRoot)
if listing.DirectoryPath == "" {
listing.DirectoryPath = requested
}
if listing.Entries == nil {
listing.Entries = []FileEntry{}
}
return listing
}
func normalizeFileEntry(_ WorkspaceRoot, requested LogicalPath, entry FileEntry) FileEntry {
if entry.Path == "" {
entry.Path = requested
}
if entry.Name == "" {
entry.Name = LogicalPathBase(entry.Path)
}
return entry
}