mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat: web viewer, project-local config, selective sync, Claude Code plugin
- sfs-web (cmd/sfs-web, internal/webapp): read-only Obsidian-style web UI serving a local folder (default) or an sfs remote; markdown rendering with [[wikilinks]], task lists and tables, file downloads with ETags, per-file provenance from the journals; added to goreleaser builds - .sfs project file (internal/config): per-folder volume/remote/include settings that travel with the folder, win over the global registry, and never sync; daemon picks up edits live - .sfsignore + include lists (internal/syncer): gitignore-style selective sync with ! re-includes, applied symmetrically in scan and materialize; newly ignored files stop syncing without being deleted anywhere - Claude Code plugin (plugin/, .claude-plugin/): sfs skill, /sfs:mount and /sfs:status commands, turn-boundary sync hooks (blocking pull on prompt, async push on stop); installable via the repo's marketplace manifest - CLAUDE.md and .claude project settings for Claude Code development Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHEUaYfFHhmDvqLYw74Ehz
This commit is contained in:
co-authored by
Claude Fable 5
parent
d22fe71d1c
commit
2d29ff8aac
@@ -0,0 +1,117 @@
|
||||
// sfs-web serves a read-only website for a folder or an sfs remote: browse
|
||||
// folders and files, read rendered markdown (Obsidian-style, including
|
||||
// [[wikilinks]]), and download any file.
|
||||
//
|
||||
// Two sources:
|
||||
//
|
||||
// - a local folder, served straight from disk (the default — on an sfs
|
||||
// mount the daemon keeps it fresh, so this is the simplest deployment);
|
||||
// - an sfs remote, read directly from the object store with per-file
|
||||
// provenance from the journals — no mount, daemon, or local state needed.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// sfs-web # serve the current directory
|
||||
// sfs-web ./notes # serve a folder
|
||||
// sfs-web s3://bucket/prefix # serve an sfs remote
|
||||
// sfs-web --remote gs://bucket/prefix --addr :8080
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/runbear-io/sfs/internal/remote"
|
||||
"github.com/runbear-io/sfs/internal/webapp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
remoteURL := flag.String("remote", "", "sfs remote to serve (s3://bucket/prefix, gs://bucket/prefix, file:///path)")
|
||||
dir := flag.String("dir", "", "local folder to serve (default: current directory)")
|
||||
addr := flag.String("addr", ":4173", "address to listen on")
|
||||
volume := flag.String("volume", "", "volume display name (default: folder or remote basename)")
|
||||
refresh := flag.Duration("refresh", 10*time.Second, "how long to cache the file listing")
|
||||
flag.Parse()
|
||||
|
||||
// Positional argument: a URL selects remote mode, anything else is a
|
||||
// folder. With nothing specified at all, serve the current directory.
|
||||
if *remoteURL == "" && *dir == "" && flag.NArg() > 0 {
|
||||
if arg := flag.Arg(0); strings.Contains(arg, "://") {
|
||||
*remoteURL = arg
|
||||
} else {
|
||||
*dir = arg
|
||||
}
|
||||
}
|
||||
if *remoteURL != "" && *dir != "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: sfs-web [folder | remote-url] [--addr :4173] (--remote and --dir are mutually exclusive)")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *remoteURL == "" && *dir == "" {
|
||||
*dir = "."
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var src webapp.Source
|
||||
var display, name string
|
||||
if *dir != "" {
|
||||
abs, err := filepath.Abs(*dir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if fi, err := os.Stat(abs); err != nil || !fi.IsDir() {
|
||||
fmt.Fprintf(os.Stderr, "error: %s is not a directory\n", abs)
|
||||
os.Exit(1)
|
||||
}
|
||||
src = &webapp.DirSource{Root: abs}
|
||||
display, name = abs, filepath.Base(abs)
|
||||
} else {
|
||||
be, err := remote.Open(ctx, *remoteURL)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer be.Close()
|
||||
src = &webapp.RemoteSource{Backend: be}
|
||||
display, name = *remoteURL, volumeName(*remoteURL)
|
||||
}
|
||||
if *volume != "" {
|
||||
name = *volume
|
||||
}
|
||||
srv := &webapp.Server{
|
||||
Source: src,
|
||||
Remote: display,
|
||||
Volume: name,
|
||||
Refresh: *refresh,
|
||||
}
|
||||
|
||||
shown := *addr
|
||||
if strings.HasPrefix(shown, ":") {
|
||||
shown = "localhost" + shown
|
||||
}
|
||||
fmt.Printf("sfs-web serving %s\n volume: %s\n url: http://%s\n", display, name, shown)
|
||||
if err := http.ListenAndServe(*addr, srv.Handler()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func volumeName(remoteURL string) string {
|
||||
if u, err := url.Parse(remoteURL); err == nil {
|
||||
if base := path.Base(strings.Trim(u.Path, "/")); base != "" && base != "." {
|
||||
return base
|
||||
}
|
||||
if u.Host != "" {
|
||||
return u.Host
|
||||
}
|
||||
}
|
||||
return "sfs"
|
||||
}
|
||||
+16
-5
@@ -75,6 +75,9 @@ func statusCmd() *cobra.Command {
|
||||
fmt.Println()
|
||||
}
|
||||
first = false
|
||||
if eff, _, found, err := config.EffectiveMount(folder); err == nil && found {
|
||||
mi = eff // .sfs project file wins over the registry
|
||||
}
|
||||
fmt.Printf("%s\n", folder)
|
||||
fmt.Printf(" volume: %s\n", mi.Volume)
|
||||
if mi.Remote != "" {
|
||||
@@ -203,19 +206,27 @@ func remoteCmd() *cobra.Command {
|
||||
if err != nil || (u.Scheme != "s3" && u.Scheme != "gs" && u.Scheme != "file") {
|
||||
return fmt.Errorf("invalid remote %q (want s3://bucket/prefix, gs://bucket/prefix, or file:///path)", raw)
|
||||
}
|
||||
mi, err := mustMount(folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mi.Remote = raw
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mi, ok := mounts[folder]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s is not an sfs mount (run `sfs mnt %s` first)", folder, folder)
|
||||
}
|
||||
mi.Remote = raw
|
||||
mounts[folder] = mi
|
||||
if err := config.SaveMounts(mounts); err != nil {
|
||||
return err
|
||||
}
|
||||
proj, _, err := config.LoadProject(folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proj.Volume, proj.Remote = mi.Volume, raw
|
||||
if err := config.SaveProject(folder, proj); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("remote of %s set to %s\n", folder, raw)
|
||||
fmt.Println("run `sfs sync` to sync now (a running daemon picks it up automatically)")
|
||||
return nil
|
||||
|
||||
+9
-4
@@ -20,15 +20,20 @@ func absFolder(args []string) (string, error) {
|
||||
return filepath.Abs(arg)
|
||||
}
|
||||
|
||||
// mustMount resolves a folder's settings: the .sfs project file wins over
|
||||
// the global registry, so a folder that carries its own .sfs works even
|
||||
// before it is registered on this device.
|
||||
func mustMount(folder string) (config.MountInfo, error) {
|
||||
mounts, err := config.LoadMounts()
|
||||
mi, _, found, err := config.EffectiveMount(folder)
|
||||
if err != nil {
|
||||
return config.MountInfo{}, err
|
||||
return mi, err
|
||||
}
|
||||
mi, ok := mounts[folder]
|
||||
if !ok {
|
||||
if !found {
|
||||
return mi, fmt.Errorf("%s is not an sfs mount (run `sfs mnt %s` first)", folder, folder)
|
||||
}
|
||||
if mi.Volume == "" {
|
||||
mi.Volume = filepath.Base(folder)
|
||||
}
|
||||
return mi, nil
|
||||
}
|
||||
|
||||
|
||||
+34
-13
@@ -41,29 +41,48 @@ daemon keeps the folder in sync until "sfs umnt".`,
|
||||
return err
|
||||
}
|
||||
|
||||
// Settings resolution: flags win, then the folder's .sfs file,
|
||||
// then the global registry. The result is written back to both,
|
||||
// so the project file travels with the folder and the registry
|
||||
// knows which mounts are active on this device.
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mi, exists := mounts[folder]
|
||||
if exists {
|
||||
if volume != "" && volume != mi.Volume {
|
||||
return fmt.Errorf("%s is already mounted as volume %q", folder, mi.Volume)
|
||||
}
|
||||
} else {
|
||||
v := volume
|
||||
if v == "" {
|
||||
v = filepath.Base(folder)
|
||||
}
|
||||
mi = config.MountInfo{Volume: v}
|
||||
reg, registered := mounts[folder]
|
||||
proj, _, err := config.LoadProject(folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remoteURL != "" {
|
||||
mi.Remote = remoteURL
|
||||
effVolume := proj.Volume
|
||||
if effVolume == "" && registered {
|
||||
effVolume = reg.Volume
|
||||
}
|
||||
if volume != "" && effVolume != "" && volume != effVolume {
|
||||
return fmt.Errorf("%s is already mounted as volume %q", folder, effVolume)
|
||||
}
|
||||
if volume != "" {
|
||||
effVolume = volume
|
||||
}
|
||||
if effVolume == "" {
|
||||
effVolume = filepath.Base(folder)
|
||||
}
|
||||
effRemote := remoteURL
|
||||
if effRemote == "" {
|
||||
effRemote = proj.Remote
|
||||
}
|
||||
if effRemote == "" && registered {
|
||||
effRemote = reg.Remote
|
||||
}
|
||||
mi := config.MountInfo{Volume: effVolume, Remote: effRemote}
|
||||
mounts[folder] = mi
|
||||
if err := config.SaveMounts(mounts); err != nil {
|
||||
return err
|
||||
}
|
||||
proj.Volume, proj.Remote = effVolume, effRemote
|
||||
if err := config.SaveProject(folder, proj); err != nil {
|
||||
return err
|
||||
}
|
||||
vdir, err := config.VolumeDir(mi.Volume)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -96,6 +115,8 @@ daemon keeps the folder in sync until "sfs umnt".`,
|
||||
fmt.Printf(" remote: (none — local only; set one with `sfs remote set %s <url>`)\n", folder)
|
||||
}
|
||||
fmt.Printf(" device: %s (%s) as %s\n", dev.Name, dev.ID, dev.Author)
|
||||
fmt.Printf(" config: %s (volume/remote/include; add a .sfsignore next to it to exclude paths)\n",
|
||||
filepath.Join(folder, config.ProjectFile))
|
||||
printCycle(res)
|
||||
|
||||
if foreground {
|
||||
|
||||
Reference in New Issue
Block a user