Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/server/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"pi-web/internal/chat"
"pi-web/internal/sessions"
"pi-web/internal/workers"
)

Expand Down Expand Up @@ -497,7 +498,7 @@ func TestHandleNewSessionPreinitializesWorker(t *testing.T) {
}

// Verify file was created
projectDir := filepath.Join(root, "--tmp-test-project--")
projectDir := filepath.Join(root, sessions.EncodeProjectName("/tmp/test-project"))
entries, err := os.ReadDir(projectDir)
if err != nil {
t.Fatalf("expected project dir to exist: %v", err)
Expand Down Expand Up @@ -536,7 +537,7 @@ func TestHandleNewSessionCopiesSourceModelAndThinking(t *testing.T) {
if fake.setModelSessionID != "" || fake.setThinkingSessionID != "" {
t.Fatalf("new session initialization should not append visible setting changes, got setModel=%q setThinking=%q", fake.setModelSessionID, fake.setThinkingSessionID)
}
projectDir := filepath.Join(root, "--tmp-test-project--")
projectDir := filepath.Join(root, sessions.EncodeProjectName("/tmp/test-project"))
data, err := os.ReadFile(filepath.Join(projectDir, id))
if err != nil {
t.Fatal(err)
Expand Down
103 changes: 96 additions & 7 deletions internal/sessions/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package sessions

import (
"bufio"
"bytes"
"crypto/rand"
"encoding/json"
"errors"
Expand Down Expand Up @@ -171,12 +170,12 @@ func ParseSummary(path, dirName, fileName string) (SessionSummary, error) {
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
line := strings.TrimSpace(scanner.Text())
if len(line) == 0 {
continue
}
var raw summaryLine
if err := json.Unmarshal(line, &raw); err != nil {
if err := json.Unmarshal([]byte(line), &raw); err != nil {
continue
}
switch raw.Type {
Expand Down Expand Up @@ -502,30 +501,60 @@ func sessionHeaderKey(raw map[string]any) string {
return id + "\x00" + timestamp + "\x00" + cwd
}

// cleanProjectName reverses EncodeProjectName for display purposes.
// It handles both the new escape-based encoding (using _ as sentinel) and
// the legacy encoding (where - stood for /).
func cleanProjectName(dirName string) string {
s := strings.TrimPrefix(dirName, "--")
s = strings.TrimSuffix(s, "--")
s = strings.ReplaceAll(s, "--", "/")
s = decodeProjectBody(s)
return s
}

// EncodeProjectName converts an absolute filesystem path into a safe
// directory name by escaping / and _. The result is wrapped with "--"
// so callers can recognise encoded project directories.
//
// /home/user/my-project → --home_-user_-my-project--
// /home/user/_cache → --home_-user_-__cache--
func EncodeProjectName(path string) string {
s := strings.TrimSpace(path)
s = strings.Trim(s, "/")
s = strings.ReplaceAll(s, "/", "-")
// Escape _ first, then /. Order matters: we must double _ before we
// introduce any new _ in the / escape sequence.
s = strings.ReplaceAll(s, "_", "__")
s = strings.ReplaceAll(s, "/", "_-")
return "--" + s + "--"
}

// DecodeProjectName reverses EncodeProjectName. It accepts both the
// new escape-based encoding and the legacy encoding (where - meant /)
// so that existing session directories continue to work.
func DecodeProjectName(dirName string) string {
s := strings.TrimPrefix(dirName, "--")
s = strings.TrimSuffix(s, "--")
s = strings.ReplaceAll(s, "-", "/")
s = decodeProjectBody(s)
if s != "" && !strings.HasPrefix(s, "/") {
s = "/" + s
}
return s
}

// decodeProjectBody decodes the content between the "--" wrappers.
// New format (contains _): __ → _, _- → /
// Legacy format (no _): - → /
func decodeProjectBody(s string) string {
if strings.Contains(s, "_") {
// New escape-based encoding. Order: unescape / first, then _.
s = strings.ReplaceAll(s, "_-", "/")
s = strings.ReplaceAll(s, "__", "_")
} else {
// Legacy encoding: every - was a /.
s = strings.ReplaceAll(s, "-", "/")
}
return s
}

const maxRecentLocations = 10

type recentLocationDir struct {
Expand Down Expand Up @@ -556,7 +585,7 @@ func ListRecentLocations(sessionsDir string) ([]string, error) {
locations := make([]string, 0, maxRecentLocations)
seen := make(map[string]bool)
for _, dir := range dirs {
loc := DecodeProjectName(dir.name)
loc := resolveLocation(sessionsDir, dir.name)
if loc == "" || seen[loc] {
continue
}
Expand All @@ -569,6 +598,66 @@ func ListRecentLocations(sessionsDir string) ([]string, error) {
return locations, nil
}

// resolveLocation returns the projects absolute path for the given
// project directory name. It first tries DecodeProjectName; if the
// result exists on disk it is returned directly. Otherwise it falls
// back to reading the cwd from a session JSONL file inside the
// directory — this recovers legacy-encoded directories whose names
// contain literal hyphens that DecodeProjectName misinterprets as
// path separators.
func resolveLocation(sessionsDir, dirName string) string {
loc := DecodeProjectName(dirName)
if loc != "" {
if info, err := os.Stat(loc); err == nil && info.IsDir() {
return loc
}
}
// Decoded path doesn't exist (or decoded to empty). Try to recover
// the real cwd from a session file inside the project directory.
cwd := readSessionCWD(filepath.Join(sessionsDir, dirName))
if cwd != "" {
return cwd
}
return loc
}

// readSessionCWD opens a *.jsonl file in dir, reads its session header
// line, and returns the cwd field. Returns "" on any error. Any file
// in the directory will do — all sessions in the same project share
// the same cwd.
func readSessionCWD(dir string) string {
matches, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
if err != nil || len(matches) == 0 {
return ""
}
f, err := os.Open(matches[0])
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 64*1024), 1*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) == 0 {
continue
}
var raw struct {
Type string `json:"type"`
CWD string `json:"cwd"`
}
if err := json.Unmarshal([]byte(line), &raw); err != nil {
continue
}
if raw.Type == "session" && raw.CWD != "" {
return raw.CWD
}
// Only the first (session) line matters.
break
}
return ""
}

type InitialSettings struct {
ModelProvider string
ModelID string
Expand Down
86 changes: 79 additions & 7 deletions internal/sessions/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ func TestEncodeProjectName(t *testing.T) {
input string
expected string
}{
{"/Users/setkyar/pi-web", "--Users-setkyar-pi-web--"},
{"/Users/setkyar", "--Users-setkyar--"},
{"/home/user/project", "--home-user-project--"},
{"/a/b/c/d", "--a-b-c-d--"},
{"/Users/setkyar", "--Users_-setkyar--"},
{"/home/user/project", "--home_-user_-project--"},
{"/a/b/c/d", "--a_-b_-c_-d--"},
{"/Users/setkyar/pi-web", "--Users_-setkyar_-pi-web--"},
{"/Users/setkyar/my-project", "--Users_-setkyar_-my-project--"},
{"/Users/setkyar/_cache", "--Users_-setkyar_-__cache--"},
{"/a/_b/_c", "--a_-__b_-__c--"},
}
for _, tt := range tests {
got := EncodeProjectName(tt.input)
Expand All @@ -32,6 +35,13 @@ func TestDecodeProjectName(t *testing.T) {
input string
expected string
}{
// New format
{"--Users_-setkyar--", "/Users/setkyar"},
{"--home_-user_-project--", "/home/user/project"},
{"--a_-b_-c_-d--", "/a/b/c/d"},
{"--Users_-setkyar_-my-project--", "/Users/setkyar/my-project"},
{"--Users_-setkyar_-__cache--", "/Users/setkyar/_cache"},
// Legacy format (no _ in body) — backward compatible.
{"--Users-setkyar--", "/Users/setkyar"},
{"--home-user-project--", "/home/user/project"},
{"--a-b-c-d--", "/a/b/c/d"},
Expand All @@ -49,6 +59,11 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
"/Users/setkyar",
"/home/user/project",
"/a/b/c/d",
"/Users/setkyar/my-project",
"/Users/setkyar/_cache",
"/a/_b/_c",
"/project-with-hyphens/sub_dir",
"/underscore_test/path",
}
for _, p := range paths {
encoded := EncodeProjectName(p)
Expand Down Expand Up @@ -151,6 +166,62 @@ func TestListRecentLocationsReturnsNewestBoundedLocations(t *testing.T) {
}
}

func TestListRecentLocationsRecoversLegacyHyphenatedPaths(t *testing.T) {
tmp := t.TempDir()

// Simulate a legacy-encoded directory for a path that contains
// literal hyphens: /tmp/my-project → --tmp-my-project--
legacyDir := filepath.Join(tmp, "--tmp-my-project--")
if err := os.MkdirAll(legacyDir, 0755); err != nil {
t.Fatal(err)
}
// Write a session file with the real cwd in the header.
sessionPath := filepath.Join(legacyDir, "2026-05-08T10-00-00.000Z_abc.jsonl")
content := `{"type":"session","version":3,"id":"abc","timestamp":"2026-05-08T10:00:00Z","cwd":"/tmp/my-project"}` + "\n"
if err := os.WriteFile(sessionPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}

locations, err := ListRecentLocations(tmp)
if err != nil {
t.Fatal(err)
}
if len(locations) == 0 {
t.Fatal("expected at least 1 location")
}
if locations[0] != "/tmp/my-project" {
t.Fatalf("expected recovered path /tmp/my-project, got %q", locations[0])
}
}

func TestResolveLocationReturnsDecodedPathWhenOnDisk(t *testing.T) {
tmp := t.TempDir()

// Create a real project directory so os.Stat succeeds.
realPath := filepath.Join(tmp, "my-project")
if err := os.MkdirAll(realPath, 0755); err != nil {
t.Fatal(err)
}

// Create the new-format encoded directory under a sessions root.
sessionsDir := filepath.Join(tmp, "sessions")
encodedDir := filepath.Join(sessionsDir, EncodeProjectName(realPath))
if err := os.MkdirAll(encodedDir, 0755); err != nil {
t.Fatal(err)
}

locations, err := ListRecentLocations(sessionsDir)
if err != nil {
t.Fatal(err)
}
if len(locations) == 0 {
t.Fatal("expected at least 1 location")
}
if locations[0] != realPath {
t.Fatalf("expected %q, got %q", realPath, locations[0])
}
}

func TestCreateSessionFile(t *testing.T) {
tmpDir := t.TempDir()
sessDir := filepath.Join(tmpDir, "sessions")
Expand Down Expand Up @@ -512,12 +583,13 @@ func TestParseSummaryFallsBackToDirNameWhenCwdMissing(t *testing.T) {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
s, err := ParseSummary(path, "--Users-setkyar--pi--web--", "s.jsonl")
// dir name as produced by EncodeProjectName for /Users/setkyar/pi/web.
s, err := ParseSummary(path, "--Users_-setkyar_-pi_-web--", "s.jsonl")
if err != nil {
t.Fatal(err)
}
if s.Project != "Users-setkyar/pi/web" {
t.Errorf("Project = %q, want %q", s.Project, "Users-setkyar/pi/web")
if s.Project != "Users/setkyar/pi/web" {
t.Errorf("Project = %q, want %q", s.Project, "Users/setkyar/pi/web")
}
}

Expand Down
Loading