diff --git a/DEV.md b/DEV.md index 1ddb897..5f52130 100644 --- a/DEV.md +++ b/DEV.md @@ -101,6 +101,8 @@ internal/tui/select_test.go | `/Users/uzulla/プロジェクト/api` | `______.api-f10f1a6e` (非ASCIIは1文字あたり `_` + suffix) | | `/srv/app-v1.2.3/api` | `srv.app-v1.2.3.api-597dbea5` | +ホームディレクトリそのもの(相対パスが空になる)やファイルシステムのルートでは相対パス由来の名前が空になる。この場合は `session.FallbackName()` が `$HOME` を取り除かないフルパスから名前を生成する(例: `/home/developer` → `home.developer`)。フルパスでも空になるルート `/` のみ `shell` に固定フォールバックする。`shp` (引数なし) / `shp -f` / `shp --print-name` はいずれも `session.FromCwdOrFallback()` を通すので、ホーム/ルートでも空にならず一貫して起動・出力できる。 + MVP ではこのルールは固定。将来は `~/work` や `~/src` を root として相対化するなどの設定を追加できる設計にしてある (`session.FromPath(path, home)` で home を引数化済み)。 ## attach の実行方式 @@ -118,7 +120,7 @@ attach には常に `--dir .` を渡す。既存セッションへの attach で - 空行を除外 - `NAME STATUS` のヘッダ行を除外 - 各行の先頭カラムをセッション名として扱う -- 候補が0件なら標準エラーに `No shpool sessions found.` を表示して終了 +- 既存セッションが0件でも、cwd 由来名(空ならフルパス由来のフォールバック名)を既定候補として picker を表示し、`shp` がそのまま起動できるようにする ## TUI @@ -147,8 +149,8 @@ MVP では fuzzy / negative / 正規表現 / 複数選択 / preview pane / sort | 状況 | 出力 | 終了コード | | --- | --- | --- | | `shpool` が PATH に無い | `Error: shpool command not found in PATH. Please install shpool first` | 1 | -| `shpool list` で一覧取得不可 | cwd 候補だけを表示。cwd 名も空なら `No shpool sessions found.` | 0 | +| `shpool list` で一覧取得不可 | cwd 候補(空ならフォールバック名)だけを表示 | 0 | | `shpool list` 失敗 (一覧取得不可として扱わないもの) | `Error: shpool list failed: ` | 1 | -| セッション0件 | `No shpool sessions found.` (stderr) | 0 | +| セッション0件 | cwd 候補(空ならフォールバック名)だけの picker を表示 | 0 | | TUI でキャンセル | (何も出さない) | 0 | | 不正な引数 | `Error: too many arguments` 等 + usage | 1 | diff --git a/cmd/shp/main.go b/cmd/shp/main.go index 9e1a382..dca7b0a 100644 --- a/cmd/shp/main.go +++ b/cmd/shp/main.go @@ -30,6 +30,9 @@ Usage: shp -h | --help Show this help. New sessions are created in the current directory. +In the home directory (or filesystem root), where no name can be derived from +a relative path, the session name falls back to one derived from the full path +(e.g. "home."), so shp still launches instead of dead-ending. shpool must be installed and on PATH. ` @@ -64,7 +67,7 @@ func run(args []string) error { if len(rest) > 0 { return fmt.Errorf("--print-name takes no arguments") } - name, err := session.FromCwd() + name, err := session.FromCwdOrFallback() if err != nil { return err } @@ -81,15 +84,13 @@ func run(args []string) error { return fmt.Errorf("too many arguments") } - // `shp -f` without a name: force-attach directly to the cwd-derived name. + // `shp -f` without a name: force-attach directly to the cwd-derived name, + // or the full-path fallback when the cwd yields none (home / root). if force { - name, err := session.FromCwd() + name, err := session.FromCwdOrFallback() if err != nil { return err } - if name == "" { - return fmt.Errorf("could not derive a session name from the current directory") - } return shpool.Attach(name, true) } @@ -98,7 +99,11 @@ func run(args []string) error { } func runPicker() error { - cwdName, err := session.FromCwd() + // Use the full-path fallback when the cwd yields no name (the home + // directory itself or the filesystem root) so the picker always has a + // default create-entry, just like any other directory, instead of + // dead-ending with "no sessions". + cwdName, err := session.FromCwdOrFallback() if err != nil { return err } @@ -111,11 +116,6 @@ func runPicker() error { sessions = nil } - if cwdName == "" && len(sessions) == 0 { - fmt.Fprintln(os.Stderr, "No shpool sessions found.") - return nil - } - picked, err := tui.SelectWithDefault(sessions, cwdName) if err != nil { if errors.Is(err, tui.ErrCancelled) { diff --git a/internal/session/name.go b/internal/session/name.go index 3798644..8000597 100644 --- a/internal/session/name.go +++ b/internal/session/name.go @@ -20,6 +20,40 @@ func FromCwd() (string, error) { return FromPath(cwd, home), nil } +// FromCwdOrFallback returns the cwd-derived session name, falling back to +// FallbackName when the cwd yields no name (the home directory itself or the +// filesystem root). The result is always non-empty, so callers can attach or +// print a name without dead-ending. +func FromCwdOrFallback() (string, error) { + name, err := FromCwd() + if err != nil { + return "", err + } + if name != "" { + return name, nil + } + return FallbackName() +} + +// FallbackName returns a session name to use when FromCwd yields no name, +// which happens in the home directory itself or at the filesystem root. It +// derives the name from the full absolute path (without stripping the home +// prefix), so the home directory maps to e.g. "home.developer" rather than +// dead-ending. This keeps the name unique and consistent with FromPath's +// naming rules. It falls back to "shell" only when even the full path yields +// nothing (the filesystem root). +func FallbackName() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get cwd: %w", err) + } + name := FromPath(cwd, "") + if name == "" { + name = "shell" + } + return name, nil +} + // FromPath converts an absolute path to a session name. // If home is non-empty and path is under home, the home prefix is stripped first. func FromPath(path, home string) string { diff --git a/internal/session/name_test.go b/internal/session/name_test.go index ab2211b..d6ce463 100644 --- a/internal/session/name_test.go +++ b/internal/session/name_test.go @@ -1,6 +1,9 @@ package session -import "testing" +import ( + "os" + "testing" +) func TestFromPath(t *testing.T) { cases := []struct { @@ -89,6 +92,81 @@ func TestFromPath_JapaneseLen(t *testing.T) { } } +func TestFallbackName_DerivesFromFullPath(t *testing.T) { + t.Chdir(t.TempDir()) + + got, err := FallbackName() + if err != nil { + t.Fatalf("FallbackName() error: %v", err) + } + // The full (home-prefix-unstripped) path is used, so the name must match + // FromPath with no home, never be empty, and reflect the directory. + // Resolve the working directory via Getwd (TempDir may contain symlinks). + dir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd error: %v", err) + } + want := FromPath(dir, "") + if got != want { + t.Errorf("FallbackName() = %q, want %q", got, want) + } + if got == "" { + t.Errorf("FallbackName() returned empty name for %q", dir) + } +} + +func TestFallbackName_RootFallsBackToShell(t *testing.T) { + t.Chdir("/") + + got, err := FallbackName() + if err != nil { + t.Fatalf("FallbackName() error: %v", err) + } + if got != "shell" { + t.Errorf("FallbackName() at root = %q, want %q", got, "shell") + } +} + +func TestFromCwdOrFallback_AtHomeUsesFallback(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory: %v", err) + } + t.Chdir(home) + + // The real trigger for the fallback: at home, FromCwd derives nothing. + if got, _ := FromCwd(); got != "" { + t.Fatalf("FromCwd() at home = %q, want empty (precondition for fallback)", got) + } + + got, err := FromCwdOrFallback() + if err != nil { + t.Fatalf("FromCwdOrFallback() error: %v", err) + } + if got == "" { + t.Errorf("FromCwdOrFallback() at home = %q, want non-empty", got) + } + if want, _ := FallbackName(); got != want { + t.Errorf("FromCwdOrFallback() = %q, want FallbackName() = %q", got, want) + } +} + +func TestFromCwdOrFallback_OutsideHomeUsesCwdName(t *testing.T) { + t.Chdir(t.TempDir()) + + cwd, _ := FromCwd() + if cwd == "" { + t.Skip("temp dir derived an empty name; cannot assert passthrough") + } + got, err := FromCwdOrFallback() + if err != nil { + t.Fatalf("FromCwdOrFallback() error: %v", err) + } + if got != cwd { + t.Errorf("FromCwdOrFallback() = %q, want FromCwd() = %q", got, cwd) + } +} + func TestFromPath_DisambiguatesSanitizedCollisions(t *testing.T) { gotSpace := FromPath("/Users/u/foo bar", "/Users/u") gotUnderscore := FromPath("/Users/u/foo_bar", "/Users/u")