diff --git a/backend/internal/services/extensions.go b/backend/internal/services/extensions.go index 6051d63..0b96b27 100644 --- a/backend/internal/services/extensions.go +++ b/backend/internal/services/extensions.go @@ -9,6 +9,7 @@ import ( "strings" "sproutclaw-web/internal/models" + "sproutclaw-web/internal/utils" ) var npmSpecRe = regexp.MustCompile(`^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$`) @@ -206,5 +207,5 @@ func ToggleExtension(agentDir, extPath string, enable bool) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } - return os.Rename(src, dst) + return utils.Move(src, dst) } diff --git a/backend/internal/services/skills.go b/backend/internal/services/skills.go index 4363f40..e5ff751 100644 --- a/backend/internal/services/skills.go +++ b/backend/internal/services/skills.go @@ -7,6 +7,7 @@ import ( "strings" "sproutclaw-web/internal/models" + "sproutclaw-web/internal/utils" ) // ListSkills returns all skills (enabled and disabled) under agentDir. @@ -65,7 +66,7 @@ func ToggleSkill(agentDir, skillPath string, enable bool) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } - return os.Rename(src, dst) + return utils.Move(src, dst) } func isUnderDir(path, dir string) bool { diff --git a/backend/internal/utils/move.go b/backend/internal/utils/move.go new file mode 100644 index 0000000..dbbe0fc --- /dev/null +++ b/backend/internal/utils/move.go @@ -0,0 +1,111 @@ +package utils + +import ( + "fmt" + "io" + "os" + "path/filepath" + "syscall" +) + +// Move moves src to dst, including across filesystems. +// It first tries os.Rename; if that fails because src and dst are on different +// devices (EXDEV), it falls back to a recursive copy followed by removal of src. +func Move(src, dst string) error { + if err := os.Rename(src, dst); err == nil { + return nil + } else if !isCrossDeviceErr(err) { + return err + } + + info, err := os.Stat(src) + if err != nil { + return err + } + + if info.IsDir() { + return moveDir(src, dst) + } + return moveFile(src, dst, info) +} + +func isCrossDeviceErr(err error) bool { + if linkErr, ok := err.(*os.LinkError); ok { + return linkErr.Err == syscall.EXDEV + } + return false +} + +func moveFile(src, dst string, info os.FileInfo) error { + if err := copyFile(src, dst, info.Mode()); err != nil { + return err + } + return os.Remove(src) +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + + _, err = io.Copy(out, in) + closeErr := out.Close() + if err != nil { + return err + } + return closeErr +} + +func moveDir(src, dst string) error { + if err := copyDir(src, dst); err != nil { + return err + } + return os.RemoveAll(src) +} + +func copyDir(src, dst string) error { + srcInfo, err := os.Stat(src) + if err != nil { + return err + } + if !srcInfo.IsDir() { + return fmt.Errorf("source is not a directory: %s", src) + } + + if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil { + return err + } + + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + info, err := entry.Info() + if err != nil { + return err + } + + if entry.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err := copyFile(srcPath, dstPath, info.Mode()); err != nil { + return err + } + } + } + return nil +} diff --git a/frontend/src/components/chat/MessageList.module.css b/frontend/src/components/chat/MessageList.module.css index 2c16c20..8d26ed6 100644 --- a/frontend/src/components/chat/MessageList.module.css +++ b/frontend/src/components/chat/MessageList.module.css @@ -406,20 +406,10 @@ } .assistantAvatarSpacer, - .assistantAvatarSlot { - width: 28px; - } - - .assistantAvatar { - width: 28px; - height: 28px; - margin-top: 10px; - } - + .assistantAvatarSlot, + .assistantAvatar, .userAvatar { - width: 28px; - height: 28px; - margin-top: 10px; + display: none; } .assistantToolbar {