on main help sign in
wip files
commitd63d0c4c44cb2356645fd112cec01598301f5949 authorKujtim Hoxha <kujtimii.h@gmail.com> dateSun 13 Apr 2025, 11:29 (2 years ago) parentbd2ec29
10 files changed+760−28
internal/app/services.go+4 internal/db/db.go+126−26 internal/db/files.sql.go+309 internal/db/migrations/000001_initial.down.sql+2 internal/db/migrations/000001_initial.up.sql+22 internal/db/models.go+10 internal/db/querier.go+10 internal/db/sql/files.sql+69 internal/history/file.go+206 internal/tui/tui.go+2−2
internal/app/services.go+4
@@ -6,6 +6,7 @@ import (
66
77 "github.com/kujtimiihoxha/termai/internal/config"
88 "github.com/kujtimiihoxha/termai/internal/db"
9+ "github.com/kujtimiihoxha/termai/internal/history"
910 "github.com/kujtimiihoxha/termai/internal/logging"
1011 "github.com/kujtimiihoxha/termai/internal/lsp"
1112 "github.com/kujtimiihoxha/termai/internal/lsp/watcher"
@@ -19,6 +20,7 @@ type App struct {
1920
2021 Sessions session.Service
2122 Messages message.Service
23+ Files history.Service
2224 Permissions permission.Service
2325
2426 LSPClients map[string]*lsp.Client
@@ -31,11 +33,13 @@ func New(ctx context.Context, conn *sql.DB) *App {
3133 q := db.New(conn)
3234 sessions := session.NewService(ctx, q)
3335 messages := message.NewService(ctx, q)
36+ files := history.NewService(ctx, q)
3437
3538 app := &App{
3639 Context: ctx,
3740 Sessions: sessions,
3841 Messages: messages,
42+ Files: files,
3943 Permissions: permission.NewPermissionService(),
4044 LSPClients: make(map[string]*lsp.Client),
4145 }
internal/db/db.go+126−26
@@ -24,33 +24,63 @@ func New(db DBTX) *Queries {
2424func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
2525 q := Queries{db: db}
2626 var err error
27+ if q.createFileStmt, err = db.PrepareContext(ctx, createFile); err != nil {
28+ return nil, fmt.Errorf("error preparing query CreateFile: %w", err)
29+ }
2730 if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil {
2831 return nil, fmt.Errorf("error preparing query CreateMessage: %w", err)
2932 }
3033 if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil {
3134 return nil, fmt.Errorf("error preparing query CreateSession: %w", err)
3235 }
36+ if q.deleteFileStmt, err = db.PrepareContext(ctx, deleteFile); err != nil {
37+ return nil, fmt.Errorf("error preparing query DeleteFile: %w", err)
38+ }
3339 if q.deleteMessageStmt, err = db.PrepareContext(ctx, deleteMessage); err != nil {
3440 return nil, fmt.Errorf("error preparing query DeleteMessage: %w", err)
3541 }
3642 if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil {
3743 return nil, fmt.Errorf("error preparing query DeleteSession: %w", err)
3844 }
45+ if q.deleteSessionFilesStmt, err = db.PrepareContext(ctx, deleteSessionFiles); err != nil {
46+ return nil, fmt.Errorf("error preparing query DeleteSessionFiles: %w", err)
47+ }
3948 if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil {
4049 return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err)
4150 }
51+ if q.getFileStmt, err = db.PrepareContext(ctx, getFile); err != nil {
52+ return nil, fmt.Errorf("error preparing query GetFile: %w", err)
53+ }
54+ if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
55+ return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
56+ }
4257 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
4358 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
4459 }
4560 if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
4661 return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
4762 }
63+ if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
64+ return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
65+ }
66+ if q.listFilesBySessionStmt, err = db.PrepareContext(ctx, listFilesBySession); err != nil {
67+ return nil, fmt.Errorf("error preparing query ListFilesBySession: %w", err)
68+ }
69+ if q.listLatestSessionFilesStmt, err = db.PrepareContext(ctx, listLatestSessionFiles); err != nil {
70+ return nil, fmt.Errorf("error preparing query ListLatestSessionFiles: %w", err)
71+ }
4872 if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
4973 return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
5074 }
75+ if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
76+ return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
77+ }
5178 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
5279 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
5380 }
81+ if q.updateFileStmt, err = db.PrepareContext(ctx, updateFile); err != nil {
82+ return nil, fmt.Errorf("error preparing query UpdateFile: %w", err)
83+ }
5484 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
5585 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
5686 }
@@ -62,6 +92,11 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
6292
6393func (q *Queries) Close() error {
6494 var err error
95+ if q.createFileStmt != nil {
96+ if cerr := q.createFileStmt.Close(); cerr != nil {
97+ err = fmt.Errorf("error closing createFileStmt: %w", cerr)
98+ }
99+ }
65100 if q.createMessageStmt != nil {
66101 if cerr := q.createMessageStmt.Close(); cerr != nil {
67102 err = fmt.Errorf("error closing createMessageStmt: %w", cerr)
@@ -72,6 +107,11 @@ func (q *Queries) Close() error {
72107 err = fmt.Errorf("error closing createSessionStmt: %w", cerr)
73108 }
74109 }
110+ if q.deleteFileStmt != nil {
111+ if cerr := q.deleteFileStmt.Close(); cerr != nil {
112+ err = fmt.Errorf("error closing deleteFileStmt: %w", cerr)
113+ }
114+ }
75115 if q.deleteMessageStmt != nil {
76116 if cerr := q.deleteMessageStmt.Close(); cerr != nil {
77117 err = fmt.Errorf("error closing deleteMessageStmt: %w", cerr)
@@ -82,11 +122,26 @@ func (q *Queries) Close() error {
82122 err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr)
83123 }
84124 }
125+ if q.deleteSessionFilesStmt != nil {
126+ if cerr := q.deleteSessionFilesStmt.Close(); cerr != nil {
127+ err = fmt.Errorf("error closing deleteSessionFilesStmt: %w", cerr)
128+ }
129+ }
85130 if q.deleteSessionMessagesStmt != nil {
86131 if cerr := q.deleteSessionMessagesStmt.Close(); cerr != nil {
87132 err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr)
88133 }
89134 }
135+ if q.getFileStmt != nil {
136+ if cerr := q.getFileStmt.Close(); cerr != nil {
137+ err = fmt.Errorf("error closing getFileStmt: %w", cerr)
138+ }
139+ }
140+ if q.getFileByPathAndSessionStmt != nil {
141+ if cerr := q.getFileByPathAndSessionStmt.Close(); cerr != nil {
142+ err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
143+ }
144+ }
90145 if q.getMessageStmt != nil {
91146 if cerr := q.getMessageStmt.Close(); cerr != nil {
92147 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
@@ -97,16 +152,41 @@ func (q *Queries) Close() error {
97152 err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
98153 }
99154 }
155+ if q.listFilesByPathStmt != nil {
156+ if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
157+ err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
158+ }
159+ }
160+ if q.listFilesBySessionStmt != nil {
161+ if cerr := q.listFilesBySessionStmt.Close(); cerr != nil {
162+ err = fmt.Errorf("error closing listFilesBySessionStmt: %w", cerr)
163+ }
164+ }
165+ if q.listLatestSessionFilesStmt != nil {
166+ if cerr := q.listLatestSessionFilesStmt.Close(); cerr != nil {
167+ err = fmt.Errorf("error closing listLatestSessionFilesStmt: %w", cerr)
168+ }
169+ }
100170 if q.listMessagesBySessionStmt != nil {
101171 if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil {
102172 err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
103173 }
104174 }
175+ if q.listNewFilesStmt != nil {
176+ if cerr := q.listNewFilesStmt.Close(); cerr != nil {
177+ err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
178+ }
179+ }
105180 if q.listSessionsStmt != nil {
106181 if cerr := q.listSessionsStmt.Close(); cerr != nil {
107182 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
108183 }
109184 }
185+ if q.updateFileStmt != nil {
186+ if cerr := q.updateFileStmt.Close(); cerr != nil {
187+ err = fmt.Errorf("error closing updateFileStmt: %w", cerr)
188+ }
189+ }
110190 if q.updateMessageStmt != nil {
111191 if cerr := q.updateMessageStmt.Close(); cerr != nil {
112192 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
@@ -154,35 +234,55 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar
154234}
155235
156236type Queries struct {
157− db DBTX
158− tx *sql.Tx
159− createMessageStmt *sql.Stmt
160− createSessionStmt *sql.Stmt
161− deleteMessageStmt *sql.Stmt
162− deleteSessionStmt *sql.Stmt
163− deleteSessionMessagesStmt *sql.Stmt
164− getMessageStmt *sql.Stmt
165− getSessionByIDStmt *sql.Stmt
166− listMessagesBySessionStmt *sql.Stmt
167− listSessionsStmt *sql.Stmt
168− updateMessageStmt *sql.Stmt
169− updateSessionStmt *sql.Stmt
237+ db DBTX
238+ tx *sql.Tx
239+ createFileStmt *sql.Stmt
240+ createMessageStmt *sql.Stmt
241+ createSessionStmt *sql.Stmt
242+ deleteFileStmt *sql.Stmt
243+ deleteMessageStmt *sql.Stmt
244+ deleteSessionStmt *sql.Stmt
245+ deleteSessionFilesStmt *sql.Stmt
246+ deleteSessionMessagesStmt *sql.Stmt
247+ getFileStmt *sql.Stmt
248+ getFileByPathAndSessionStmt *sql.Stmt
249+ getMessageStmt *sql.Stmt
250+ getSessionByIDStmt *sql.Stmt
251+ listFilesByPathStmt *sql.Stmt
252+ listFilesBySessionStmt *sql.Stmt
253+ listLatestSessionFilesStmt *sql.Stmt
254+ listMessagesBySessionStmt *sql.Stmt
255+ listNewFilesStmt *sql.Stmt
256+ listSessionsStmt *sql.Stmt
257+ updateFileStmt *sql.Stmt
258+ updateMessageStmt *sql.Stmt
259+ updateSessionStmt *sql.Stmt
170260}
171261
172262func (q *Queries) WithTx(tx *sql.Tx) *Queries {
173263 return &Queries{
174− db: tx,
175− tx: tx,
176− createMessageStmt: q.createMessageStmt,
177− createSessionStmt: q.createSessionStmt,
178− deleteMessageStmt: q.deleteMessageStmt,
179− deleteSessionStmt: q.deleteSessionStmt,
180− deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
181− getMessageStmt: q.getMessageStmt,
182− getSessionByIDStmt: q.getSessionByIDStmt,
183− listMessagesBySessionStmt: q.listMessagesBySessionStmt,
184− listSessionsStmt: q.listSessionsStmt,
185− updateMessageStmt: q.updateMessageStmt,
186− updateSessionStmt: q.updateSessionStmt,
264+ db: tx,
265+ tx: tx,
266+ createFileStmt: q.createFileStmt,
267+ createMessageStmt: q.createMessageStmt,
268+ createSessionStmt: q.createSessionStmt,
269+ deleteFileStmt: q.deleteFileStmt,
270+ deleteMessageStmt: q.deleteMessageStmt,
271+ deleteSessionStmt: q.deleteSessionStmt,
272+ deleteSessionFilesStmt: q.deleteSessionFilesStmt,
273+ deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
274+ getFileStmt: q.getFileStmt,
275+ getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
276+ getMessageStmt: q.getMessageStmt,
277+ getSessionByIDStmt: q.getSessionByIDStmt,
278+ listFilesByPathStmt: q.listFilesByPathStmt,
279+ listFilesBySessionStmt: q.listFilesBySessionStmt,
280+ listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
281+ listMessagesBySessionStmt: q.listMessagesBySessionStmt,
282+ listNewFilesStmt: q.listNewFilesStmt,
283+ listSessionsStmt: q.listSessionsStmt,
284+ updateFileStmt: q.updateFileStmt,
285+ updateMessageStmt: q.updateMessageStmt,
286+ updateSessionStmt: q.updateSessionStmt,
187287 }
188288}
internal/db/files.sql.gonew file+309
@@ -0,0 +1,309 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.27.0
4+// source: files.sql
5+
6+package db
7+
8+import (
9+ "context"
10+)
11+
12+const createFile = `-- name: CreateFile :one
13+INSERT INTO files (
14+ id,
15+ session_id,
16+ path,
17+ content,
18+ version,
19+ created_at,
20+ updated_at
21+) VALUES (
22+ ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
23+)
24+RETURNING id, session_id, path, content, version, created_at, updated_at
25+`
26+
27+type CreateFileParams struct {
28+ ID string `json:"id"`
29+ SessionID string `json:"session_id"`
30+ Path string `json:"path"`
31+ Content string `json:"content"`
32+ Version string `json:"version"`
33+}
34+
35+func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) {
36+ row := q.queryRow(ctx, q.createFileStmt, createFile,
37+ arg.ID,
38+ arg.SessionID,
39+ arg.Path,
40+ arg.Content,
41+ arg.Version,
42+ )
43+ var i File
44+ err := row.Scan(
45+ &i.ID,
46+ &i.SessionID,
47+ &i.Path,
48+ &i.Content,
49+ &i.Version,
50+ &i.CreatedAt,
51+ &i.UpdatedAt,
52+ )
53+ return i, err
54+}
55+
56+const deleteFile = `-- name: DeleteFile :exec
57+DELETE FROM files
58+WHERE id = ?
59+`
60+
61+func (q *Queries) DeleteFile(ctx context.Context, id string) error {
62+ _, err := q.exec(ctx, q.deleteFileStmt, deleteFile, id)
63+ return err
64+}
65+
66+const deleteSessionFiles = `-- name: DeleteSessionFiles :exec
67+DELETE FROM files
68+WHERE session_id = ?
69+`
70+
71+func (q *Queries) DeleteSessionFiles(ctx context.Context, sessionID string) error {
72+ _, err := q.exec(ctx, q.deleteSessionFilesStmt, deleteSessionFiles, sessionID)
73+ return err
74+}
75+
76+const getFile = `-- name: GetFile :one
77+SELECT id, session_id, path, content, version, created_at, updated_at
78+FROM files
79+WHERE id = ? LIMIT 1
80+`
81+
82+func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
83+ row := q.queryRow(ctx, q.getFileStmt, getFile, id)
84+ var i File
85+ err := row.Scan(
86+ &i.ID,
87+ &i.SessionID,
88+ &i.Path,
89+ &i.Content,
90+ &i.Version,
91+ &i.CreatedAt,
92+ &i.UpdatedAt,
93+ )
94+ return i, err
95+}
96+
97+const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
98+SELECT id, session_id, path, content, version, created_at, updated_at
99+FROM files
100+WHERE path = ? AND session_id = ? LIMIT 1
101+`
102+
103+type GetFileByPathAndSessionParams struct {
104+ Path string `json:"path"`
105+ SessionID string `json:"session_id"`
106+}
107+
108+func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) {
109+ row := q.queryRow(ctx, q.getFileByPathAndSessionStmt, getFileByPathAndSession, arg.Path, arg.SessionID)
110+ var i File
111+ err := row.Scan(
112+ &i.ID,
113+ &i.SessionID,
114+ &i.Path,
115+ &i.Content,
116+ &i.Version,
117+ &i.CreatedAt,
118+ &i.UpdatedAt,
119+ )
120+ return i, err
121+}
122+
123+const listFilesByPath = `-- name: ListFilesByPath :many
124+SELECT id, session_id, path, content, version, created_at, updated_at
125+FROM files
126+WHERE path = ?
127+ORDER BY created_at DESC
128+`
129+
130+func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, error) {
131+ rows, err := q.query(ctx, q.listFilesByPathStmt, listFilesByPath, path)
132+ if err != nil {
133+ return nil, err
134+ }
135+ defer rows.Close()
136+ items := []File{}
137+ for rows.Next() {
138+ var i File
139+ if err := rows.Scan(
140+ &i.ID,
141+ &i.SessionID,
142+ &i.Path,
143+ &i.Content,
144+ &i.Version,
145+ &i.CreatedAt,
146+ &i.UpdatedAt,
147+ ); err != nil {
148+ return nil, err
149+ }
150+ items = append(items, i)
151+ }
152+ if err := rows.Close(); err != nil {
153+ return nil, err
154+ }
155+ if err := rows.Err(); err != nil {
156+ return nil, err
157+ }
158+ return items, nil
159+}
160+
161+const listFilesBySession = `-- name: ListFilesBySession :many
162+SELECT id, session_id, path, content, version, created_at, updated_at
163+FROM files
164+WHERE session_id = ?
165+ORDER BY created_at ASC
166+`
167+
168+func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) {
169+ rows, err := q.query(ctx, q.listFilesBySessionStmt, listFilesBySession, sessionID)
170+ if err != nil {
171+ return nil, err
172+ }
173+ defer rows.Close()
174+ items := []File{}
175+ for rows.Next() {
176+ var i File
177+ if err := rows.Scan(
178+ &i.ID,
179+ &i.SessionID,
180+ &i.Path,
181+ &i.Content,
182+ &i.Version,
183+ &i.CreatedAt,
184+ &i.UpdatedAt,
185+ ); err != nil {
186+ return nil, err
187+ }
188+ items = append(items, i)
189+ }
190+ if err := rows.Close(); err != nil {
191+ return nil, err
192+ }
193+ if err := rows.Err(); err != nil {
194+ return nil, err
195+ }
196+ return items, nil
197+}
198+
199+const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
200+SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
201+FROM files f
202+INNER JOIN (
203+ SELECT path, MAX(created_at) as max_created_at
204+ FROM files
205+ GROUP BY path
206+) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
207+WHERE f.session_id = ?
208+ORDER BY f.path
209+`
210+
211+func (q *Queries) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
212+ rows, err := q.query(ctx, q.listLatestSessionFilesStmt, listLatestSessionFiles, sessionID)
213+ if err != nil {
214+ return nil, err
215+ }
216+ defer rows.Close()
217+ items := []File{}
218+ for rows.Next() {
219+ var i File
220+ if err := rows.Scan(
221+ &i.ID,
222+ &i.SessionID,
223+ &i.Path,
224+ &i.Content,
225+ &i.Version,
226+ &i.CreatedAt,
227+ &i.UpdatedAt,
228+ ); err != nil {
229+ return nil, err
230+ }
231+ items = append(items, i)
232+ }
233+ if err := rows.Close(); err != nil {
234+ return nil, err
235+ }
236+ if err := rows.Err(); err != nil {
237+ return nil, err
238+ }
239+ return items, nil
240+}
241+
242+const listNewFiles = `-- name: ListNewFiles :many
243+SELECT id, session_id, path, content, version, created_at, updated_at
244+FROM files
245+WHERE is_new = 1
246+ORDER BY created_at DESC
247+`
248+
249+func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) {
250+ rows, err := q.query(ctx, q.listNewFilesStmt, listNewFiles)
251+ if err != nil {
252+ return nil, err
253+ }
254+ defer rows.Close()
255+ items := []File{}
256+ for rows.Next() {
257+ var i File
258+ if err := rows.Scan(
259+ &i.ID,
260+ &i.SessionID,
261+ &i.Path,
262+ &i.Content,
263+ &i.Version,
264+ &i.CreatedAt,
265+ &i.UpdatedAt,
266+ ); err != nil {
267+ return nil, err
268+ }
269+ items = append(items, i)
270+ }
271+ if err := rows.Close(); err != nil {
272+ return nil, err
273+ }
274+ if err := rows.Err(); err != nil {
275+ return nil, err
276+ }
277+ return items, nil
278+}
279+
280+const updateFile = `-- name: UpdateFile :one
281+UPDATE files
282+SET
283+ content = ?,
284+ version = ?,
285+ updated_at = strftime('%s', 'now')
286+WHERE id = ?
287+RETURNING id, session_id, path, content, version, created_at, updated_at
288+`
289+
290+type UpdateFileParams struct {
291+ Content string `json:"content"`
292+ Version string `json:"version"`
293+ ID string `json:"id"`
294+}
295+
296+func (q *Queries) UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) {
297+ row := q.queryRow(ctx, q.updateFileStmt, updateFile, arg.Content, arg.Version, arg.ID)
298+ var i File
299+ err := row.Scan(
300+ &i.ID,
301+ &i.SessionID,
302+ &i.Path,
303+ &i.Content,
304+ &i.Version,
305+ &i.CreatedAt,
306+ &i.UpdatedAt,
307+ )
308+ return i, err
309+}
internal/db/migrations/000001_initial.down.sql+2
@@ -1,8 +1,10 @@
11DROP TRIGGER IF EXISTS update_sessions_updated_at;
22DROP TRIGGER IF EXISTS update_messages_updated_at;
3+DROP TRIGGER IF EXISTS update_files_updated_at;
34
45DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
56DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
67
78DROP TABLE IF EXISTS sessions;
89DROP TABLE IF EXISTS messages;
10+DROP TABLE IF EXISTS files;
internal/db/migrations/000001_initial.up.sql+22
@@ -18,6 +18,28 @@ UPDATE sessions SET updated_at = strftime('%s', 'now')
1818WHERE id = new.id;
1919END;
2020
21+-- Files
22+CREATE TABLE IF NOT EXISTS files (
23+ id TEXT PRIMARY KEY,
24+ session_id TEXT NOT NULL,
25+ path TEXT NOT NULL,
26+ content TEXT NOT NULL,
27+ version TEXT NOT NULL,
28+ created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
29+ updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
30+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
31+);
32+
33+CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id);
34+CREATE INDEX IF NOT EXISTS idx_files_path ON files (path);
35+
36+CREATE TRIGGER IF NOT EXISTS update_files_updated_at
37+AFTER UPDATE ON files
38+BEGIN
39+UPDATE files SET updated_at = strftime('%s', 'now')
40+WHERE id = new.id;
41+END;
42+
2143-- Messages
2244CREATE TABLE IF NOT EXISTS messages (
2345 id TEXT PRIMARY KEY,
internal/db/models.go+10
@@ -8,6 +8,16 @@ import (
88 "database/sql"
99)
1010
11+type File struct {
12+ ID string `json:"id"`
13+ SessionID string `json:"session_id"`
14+ Path string `json:"path"`
15+ Content string `json:"content"`
16+ Version string `json:"version"`
17+ CreatedAt int64 `json:"created_at"`
18+ UpdatedAt int64 `json:"updated_at"`
19+}
20+
1121type Message struct {
1222 ID string `json:"id"`
1323 SessionID string `json:"session_id"`
internal/db/querier.go+10
@@ -9,15 +9,25 @@ import (
99)
1010
1111type Querier interface {
12+ CreateFile(ctx context.Context, arg CreateFileParams) (File, error)
1213 CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error)
1314 CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
15+ DeleteFile(ctx context.Context, id string) error
1416 DeleteMessage(ctx context.Context, id string) error
1517 DeleteSession(ctx context.Context, id string) error
18+ DeleteSessionFiles(ctx context.Context, sessionID string) error
1619 DeleteSessionMessages(ctx context.Context, sessionID string) error
20+ GetFile(ctx context.Context, id string) (File, error)
21+ GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
1722 GetMessage(ctx context.Context, id string) (Message, error)
1823 GetSessionByID(ctx context.Context, id string) (Session, error)
24+ ListFilesByPath(ctx context.Context, path string) ([]File, error)
25+ ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
26+ ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
1927 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
28+ ListNewFiles(ctx context.Context) ([]File, error)
2029 ListSessions(ctx context.Context) ([]Session, error)
30+ UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error)
2131 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
2232 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
2333}
internal/db/sql/files.sqlnew file+69
@@ -0,0 +1,69 @@
1+-- name: GetFile :one
2+SELECT *
3+FROM files
4+WHERE id = ? LIMIT 1;
5+
6+-- name: GetFileByPathAndSession :one
7+SELECT *
8+FROM files
9+WHERE path = ? AND session_id = ? LIMIT 1;
10+
11+-- name: ListFilesBySession :many
12+SELECT *
13+FROM files
14+WHERE session_id = ?
15+ORDER BY created_at ASC;
16+
17+-- name: ListFilesByPath :many
18+SELECT *
19+FROM files
20+WHERE path = ?
21+ORDER BY created_at DESC;
22+
23+-- name: CreateFile :one
24+INSERT INTO files (
25+ id,
26+ session_id,
27+ path,
28+ content,
29+ version,
30+ created_at,
31+ updated_at
32+) VALUES (
33+ ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
34+)
35+RETURNING *;
36+
37+-- name: UpdateFile :one
38+UPDATE files
39+SET
40+ content = ?,
41+ version = ?,
42+ updated_at = strftime('%s', 'now')
43+WHERE id = ?
44+RETURNING *;
45+
46+-- name: DeleteFile :exec
47+DELETE FROM files
48+WHERE id = ?;
49+
50+-- name: DeleteSessionFiles :exec
51+DELETE FROM files
52+WHERE session_id = ?;
53+
54+-- name: ListLatestSessionFiles :many
55+SELECT f.*
56+FROM files f
57+INNER JOIN (
58+ SELECT path, MAX(created_at) as max_created_at
59+ FROM files
60+ GROUP BY path
61+) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
62+WHERE f.session_id = ?
63+ORDER BY f.path;
64+
65+-- name: ListNewFiles :many
66+SELECT *
67+FROM files
68+WHERE is_new = 1
69+ORDER BY created_at DESC;
internal/history/file.gonew file+206
@@ -0,0 +1,206 @@
1+package history
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/google/uuid"
10+ "github.com/kujtimiihoxha/termai/internal/db"
11+ "github.com/kujtimiihoxha/termai/internal/pubsub"
12+)
13+
14+const (
15+ InitialVersion = "initial"
16+)
17+
18+type File struct {
19+ ID string
20+ SessionID string
21+ Path string
22+ Content string
23+ Version string
24+ CreatedAt int64
25+ UpdatedAt int64
26+}
27+
28+type Service interface {
29+ pubsub.Suscriber[File]
30+ Create(sessionID, path, content string) (File, error)
31+ CreateVersion(sessionID, path, content string) (File, error)
32+ Get(id string) (File, error)
33+ GetByPathAndSession(path, sessionID string) (File, error)
34+ ListBySession(sessionID string) ([]File, error)
35+ ListLatestSessionFiles(sessionID string) ([]File, error)
36+ Update(file File) (File, error)
37+ Delete(id string) error
38+ DeleteSessionFiles(sessionID string) error
39+}
40+
41+type service struct {
42+ *pubsub.Broker[File]
43+ q db.Querier
44+ ctx context.Context
45+}
46+
47+func NewService(ctx context.Context, q db.Querier) Service {
48+ return &service{
49+ Broker: pubsub.NewBroker[File](),
50+ q: q,
51+ ctx: ctx,
52+ }
53+}
54+
55+func (s *service) Create(sessionID, path, content string) (File, error) {
56+ return s.createWithVersion(sessionID, path, content, InitialVersion)
57+}
58+
59+func (s *service) CreateVersion(sessionID, path, content string) (File, error) {
60+ // Get the latest version for this path
61+ files, err := s.q.ListFilesByPath(s.ctx, path)
62+ if err != nil {
63+ return File{}, err
64+ }
65+
66+ if len(files) == 0 {
67+ // No previous versions, create initial
68+ return s.Create(sessionID, path, content)
69+ }
70+
71+ // Get the latest version
72+ latestFile := files[0] // Files are ordered by created_at DESC
73+ latestVersion := latestFile.Version
74+
75+ // Generate the next version
76+ var nextVersion string
77+ if latestVersion == InitialVersion {
78+ nextVersion = "v1"
79+ } else if strings.HasPrefix(latestVersion, "v") {
80+ versionNum, err := strconv.Atoi(latestVersion[1:])
81+ if err != nil {
82+ // If we can't parse the version, just use a timestamp-based version
83+ nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
84+ } else {
85+ nextVersion = fmt.Sprintf("v%d", versionNum+1)
86+ }
87+ } else {
88+ // If the version format is unexpected, use a timestamp-based version
89+ nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
90+ }
91+
92+ return s.createWithVersion(sessionID, path, content, nextVersion)
93+}
94+
95+func (s *service) createWithVersion(sessionID, path, content, version string) (File, error) {
96+ dbFile, err := s.q.CreateFile(s.ctx, db.CreateFileParams{
97+ ID: uuid.New().String(),
98+ SessionID: sessionID,
99+ Path: path,
100+ Content: content,
101+ Version: version,
102+ })
103+ if err != nil {
104+ return File{}, err
105+ }
106+ file := s.fromDBItem(dbFile)
107+ s.Publish(pubsub.CreatedEvent, file)
108+ return file, nil
109+}
110+
111+func (s *service) Get(id string) (File, error) {
112+ dbFile, err := s.q.GetFile(s.ctx, id)
113+ if err != nil {
114+ return File{}, err
115+ }
116+ return s.fromDBItem(dbFile), nil
117+}
118+
119+func (s *service) GetByPathAndSession(path, sessionID string) (File, error) {
120+ dbFile, err := s.q.GetFileByPathAndSession(s.ctx, db.GetFileByPathAndSessionParams{
121+ Path: path,
122+ SessionID: sessionID,
123+ })
124+ if err != nil {
125+ return File{}, err
126+ }
127+ return s.fromDBItem(dbFile), nil
128+}
129+
130+func (s *service) ListBySession(sessionID string) ([]File, error) {
131+ dbFiles, err := s.q.ListFilesBySession(s.ctx, sessionID)
132+ if err != nil {
133+ return nil, err
134+ }
135+ files := make([]File, len(dbFiles))
136+ for i, dbFile := range dbFiles {
137+ files[i] = s.fromDBItem(dbFile)
138+ }
139+ return files, nil
140+}
141+
142+func (s *service) ListLatestSessionFiles(sessionID string) ([]File, error) {
143+ dbFiles, err := s.q.ListLatestSessionFiles(s.ctx, sessionID)
144+ if err != nil {
145+ return nil, err
146+ }
147+ files := make([]File, len(dbFiles))
148+ for i, dbFile := range dbFiles {
149+ files[i] = s.fromDBItem(dbFile)
150+ }
151+ return files, nil
152+}
153+
154+func (s *service) Update(file File) (File, error) {
155+ dbFile, err := s.q.UpdateFile(s.ctx, db.UpdateFileParams{
156+ ID: file.ID,
157+ Content: file.Content,
158+ Version: file.Version,
159+ })
160+ if err != nil {
161+ return File{}, err
162+ }
163+ updatedFile := s.fromDBItem(dbFile)
164+ s.Publish(pubsub.UpdatedEvent, updatedFile)
165+ return updatedFile, nil
166+}
167+
168+func (s *service) Delete(id string) error {
169+ file, err := s.Get(id)
170+ if err != nil {
171+ return err
172+ }
173+ err = s.q.DeleteFile(s.ctx, id)
174+ if err != nil {
175+ return err
176+ }
177+ s.Publish(pubsub.DeletedEvent, file)
178+ return nil
179+}
180+
181+func (s *service) DeleteSessionFiles(sessionID string) error {
182+ files, err := s.ListBySession(sessionID)
183+ if err != nil {
184+ return err
185+ }
186+ for _, file := range files {
187+ err = s.Delete(file.ID)
188+ if err != nil {
189+ return err
190+ }
191+ }
192+ return nil
193+}
194+
195+func (s *service) fromDBItem(item db.File) File {
196+ return File{
197+ ID: item.ID,
198+ SessionID: item.SessionID,
199+ Path: item.Path,
200+ Content: item.Content,
201+ Version: item.Version,
202+ CreatedAt: item.CreatedAt,
203+ UpdatedAt: item.UpdatedAt,
204+ }
205+}
206+
internal/tui/tui.go+2−2
@@ -198,8 +198,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
198198 }
199199 return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
200200 }
201− case key.Matches(msg, keys.Logs):
202− return a, a.moveToPage(page.LogsPage)
201+ // case key.Matches(msg, keys.Logs):
202+ // return a, a.moveToPage(page.LogsPage)
203203 case msg.String() == "O":
204204 return a, a.moveToPage(page.ReplPage)
205205 case key.Matches(msg, keys.Help):