on main help sign in
wip files
d63d0c4Kujtim Hoxha
internal/app/services.go+4
@@ -6,6 +6,7 @@ import (
6
6
7 "github.com/kujtimiihoxha/termai/internal/config"
7 "github.com/kujtimiihoxha/termai/internal/config"
8 "github.com/kujtimiihoxha/termai/internal/db"
8 "github.com/kujtimiihoxha/termai/internal/db"
9+ "github.com/kujtimiihoxha/termai/internal/history"
9 "github.com/kujtimiihoxha/termai/internal/logging"
10 "github.com/kujtimiihoxha/termai/internal/logging"
10 "github.com/kujtimiihoxha/termai/internal/lsp"
11 "github.com/kujtimiihoxha/termai/internal/lsp"
11 "github.com/kujtimiihoxha/termai/internal/lsp/watcher"
12 "github.com/kujtimiihoxha/termai/internal/lsp/watcher"
@@ -19,6 +20,7 @@ type App struct {
19
20
20 Sessions session.Service
21 Sessions session.Service
21 Messages message.Service
22 Messages message.Service
23+ Files history.Service
22 Permissions permission.Service
24 Permissions permission.Service
23
25
24 LSPClients map[string]*lsp.Client
26 LSPClients map[string]*lsp.Client
@@ -31,11 +33,13 @@ func New(ctx context.Context, conn *sql.DB) *App {
31 q := db.New(conn)
33 q := db.New(conn)
32 sessions := session.NewService(ctx, q)
34 sessions := session.NewService(ctx, q)
33 messages := message.NewService(ctx, q)
35 messages := message.NewService(ctx, q)
36+ files := history.NewService(ctx, q)
34
37
35 app := &App{
38 app := &App{
36 Context: ctx,
39 Context: ctx,
37 Sessions: sessions,
40 Sessions: sessions,
38 Messages: messages,
41 Messages: messages,
42+ Files: files,
39 Permissions: permission.NewPermissionService(),
43 Permissions: permission.NewPermissionService(),
40 LSPClients: make(map[string]*lsp.Client),
44 LSPClients: make(map[string]*lsp.Client),
41 }
45 }
internal/db/db.go+126−26
@@ -24,33 +24,63 @@ func New(db DBTX) *Queries {
24func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
24func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
25 q := Queries{db: db}
25 q := Queries{db: db}
26 var err error
26 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+ }
27 if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil {
30 if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil {
28 return nil, fmt.Errorf("error preparing query CreateMessage: %w", err)
31 return nil, fmt.Errorf("error preparing query CreateMessage: %w", err)
29 }
32 }
30 if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil {
33 if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil {
31 return nil, fmt.Errorf("error preparing query CreateSession: %w", err)
34 return nil, fmt.Errorf("error preparing query CreateSession: %w", err)
32 }
35 }
36+ if q.deleteFileStmt, err = db.PrepareContext(ctx, deleteFile); err != nil {
37+ return nil, fmt.Errorf("error preparing query DeleteFile: %w", err)
38+ }
33 if q.deleteMessageStmt, err = db.PrepareContext(ctx, deleteMessage); err != nil {
39 if q.deleteMessageStmt, err = db.PrepareContext(ctx, deleteMessage); err != nil {
34 return nil, fmt.Errorf("error preparing query DeleteMessage: %w", err)
40 return nil, fmt.Errorf("error preparing query DeleteMessage: %w", err)
35 }
41 }
36 if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil {
42 if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil {
37 return nil, fmt.Errorf("error preparing query DeleteSession: %w", err)
43 return nil, fmt.Errorf("error preparing query DeleteSession: %w", err)
38 }
44 }
45+ if q.deleteSessionFilesStmt, err = db.PrepareContext(ctx, deleteSessionFiles); err != nil {
46+ return nil, fmt.Errorf("error preparing query DeleteSessionFiles: %w", err)
47+ }
39 if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil {
48 if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil {
40 return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err)
49 return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err)
41 }
50 }
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+ }
42 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
57 if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
43 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
58 return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
44 }
59 }
45 if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
60 if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
46 return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
61 return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
47 }
62 }
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+ }
48 if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
72 if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
49 return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
73 return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
50 }
74 }
75+ if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
76+ return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
77+ }
51 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
78 if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
52 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
79 return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
53 }
80 }
81+ if q.updateFileStmt, err = db.PrepareContext(ctx, updateFile); err != nil {
82+ return nil, fmt.Errorf("error preparing query UpdateFile: %w", err)
83+ }
54 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
84 if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
55 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
85 return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
56 }
86 }
@@ -62,6 +92,11 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
62
92
63func (q *Queries) Close() error {
93func (q *Queries) Close() error {
64 var err error
94 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+ }
65 if q.createMessageStmt != nil {
100 if q.createMessageStmt != nil {
66 if cerr := q.createMessageStmt.Close(); cerr != nil {
101 if cerr := q.createMessageStmt.Close(); cerr != nil {
67 err = fmt.Errorf("error closing createMessageStmt: %w", cerr)
102 err = fmt.Errorf("error closing createMessageStmt: %w", cerr)
@@ -72,6 +107,11 @@ func (q *Queries) Close() error {
72 err = fmt.Errorf("error closing createSessionStmt: %w", cerr)
107 err = fmt.Errorf("error closing createSessionStmt: %w", cerr)
73 }
108 }
74 }
109 }
110+ if q.deleteFileStmt != nil {
111+ if cerr := q.deleteFileStmt.Close(); cerr != nil {
112+ err = fmt.Errorf("error closing deleteFileStmt: %w", cerr)
113+ }
114+ }
75 if q.deleteMessageStmt != nil {
115 if q.deleteMessageStmt != nil {
76 if cerr := q.deleteMessageStmt.Close(); cerr != nil {
116 if cerr := q.deleteMessageStmt.Close(); cerr != nil {
77 err = fmt.Errorf("error closing deleteMessageStmt: %w", cerr)
117 err = fmt.Errorf("error closing deleteMessageStmt: %w", cerr)
@@ -82,11 +122,26 @@ func (q *Queries) Close() error {
82 err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr)
122 err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr)
83 }
123 }
84 }
124 }
125+ if q.deleteSessionFilesStmt != nil {
126+ if cerr := q.deleteSessionFilesStmt.Close(); cerr != nil {
127+ err = fmt.Errorf("error closing deleteSessionFilesStmt: %w", cerr)
128+ }
129+ }
85 if q.deleteSessionMessagesStmt != nil {
130 if q.deleteSessionMessagesStmt != nil {
86 if cerr := q.deleteSessionMessagesStmt.Close(); cerr != nil {
131 if cerr := q.deleteSessionMessagesStmt.Close(); cerr != nil {
87 err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr)
132 err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr)
88 }
133 }
89 }
134 }
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+ }
90 if q.getMessageStmt != nil {
145 if q.getMessageStmt != nil {
91 if cerr := q.getMessageStmt.Close(); cerr != nil {
146 if cerr := q.getMessageStmt.Close(); cerr != nil {
92 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
147 err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
@@ -97,16 +152,41 @@ func (q *Queries) Close() error {
97 err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
152 err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
98 }
153 }
99 }
154 }
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+ }
100 if q.listMessagesBySessionStmt != nil {
170 if q.listMessagesBySessionStmt != nil {
101 if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil {
171 if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil {
102 err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
172 err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
103 }
173 }
104 }
174 }
175+ if q.listNewFilesStmt != nil {
176+ if cerr := q.listNewFilesStmt.Close(); cerr != nil {
177+ err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
178+ }
179+ }
105 if q.listSessionsStmt != nil {
180 if q.listSessionsStmt != nil {
106 if cerr := q.listSessionsStmt.Close(); cerr != nil {
181 if cerr := q.listSessionsStmt.Close(); cerr != nil {
107 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
182 err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
108 }
183 }
109 }
184 }
185+ if q.updateFileStmt != nil {
186+ if cerr := q.updateFileStmt.Close(); cerr != nil {
187+ err = fmt.Errorf("error closing updateFileStmt: %w", cerr)
188+ }
189+ }
110 if q.updateMessageStmt != nil {
190 if q.updateMessageStmt != nil {
111 if cerr := q.updateMessageStmt.Close(); cerr != nil {
191 if cerr := q.updateMessageStmt.Close(); cerr != nil {
112 err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
192 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
154}
234}
155
235
156type Queries struct {
236type Queries struct {
157− db DBTX
237+ db DBTX
158− tx *sql.Tx
238+ tx *sql.Tx
239+ createFileStmt *sql.Stmt
159− createMessageStmt *sql.Stmt
240+ createMessageStmt *sql.Stmt
160− createSessionStmt *sql.Stmt
241+ createSessionStmt *sql.Stmt
242+ deleteFileStmt *sql.Stmt
161− deleteMessageStmt *sql.Stmt
243+ deleteMessageStmt *sql.Stmt
162− deleteSessionStmt *sql.Stmt
244+ deleteSessionStmt *sql.Stmt
245+ deleteSessionFilesStmt *sql.Stmt
163− deleteSessionMessagesStmt *sql.Stmt
246+ deleteSessionMessagesStmt *sql.Stmt
247+ getFileStmt *sql.Stmt
248+ getFileByPathAndSessionStmt *sql.Stmt
164− getMessageStmt *sql.Stmt
249+ getMessageStmt *sql.Stmt
165− getSessionByIDStmt *sql.Stmt
250+ getSessionByIDStmt *sql.Stmt
251+ listFilesByPathStmt *sql.Stmt
252+ listFilesBySessionStmt *sql.Stmt
253+ listLatestSessionFilesStmt *sql.Stmt
166− listMessagesBySessionStmt *sql.Stmt
254+ listMessagesBySessionStmt *sql.Stmt
255+ listNewFilesStmt *sql.Stmt
167− listSessionsStmt *sql.Stmt
256+ listSessionsStmt *sql.Stmt
257+ updateFileStmt *sql.Stmt
168− updateMessageStmt *sql.Stmt
258+ updateMessageStmt *sql.Stmt
169− updateSessionStmt *sql.Stmt
259+ updateSessionStmt *sql.Stmt
170}
260}
171
261
172func (q *Queries) WithTx(tx *sql.Tx) *Queries {
262func (q *Queries) WithTx(tx *sql.Tx) *Queries {
173 return &Queries{
263 return &Queries{
174− db: tx,
264+ db: tx,
175− tx: tx,
265+ tx: tx,
266+ createFileStmt: q.createFileStmt,
176− createMessageStmt: q.createMessageStmt,
267+ createMessageStmt: q.createMessageStmt,
177− createSessionStmt: q.createSessionStmt,
268+ createSessionStmt: q.createSessionStmt,
269+ deleteFileStmt: q.deleteFileStmt,
178− deleteMessageStmt: q.deleteMessageStmt,
270+ deleteMessageStmt: q.deleteMessageStmt,
179− deleteSessionStmt: q.deleteSessionStmt,
271+ deleteSessionStmt: q.deleteSessionStmt,
272+ deleteSessionFilesStmt: q.deleteSessionFilesStmt,
180− deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
273+ deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
181− getMessageStmt: q.getMessageStmt,
274+ getFileStmt: q.getFileStmt,
182− getSessionByIDStmt: q.getSessionByIDStmt,
275+ getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
183− listMessagesBySessionStmt: q.listMessagesBySessionStmt,
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,
184− listSessionsStmt: q.listSessionsStmt,
283+ listSessionsStmt: q.listSessionsStmt,
284+ updateFileStmt: q.updateFileStmt,
185− updateMessageStmt: q.updateMessageStmt,
285+ updateMessageStmt: q.updateMessageStmt,
186− updateSessionStmt: q.updateSessionStmt,
286+ updateSessionStmt: q.updateSessionStmt,
187 }
287 }
188}
288}
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 @@
1DROP TRIGGER IF EXISTS update_sessions_updated_at;
1DROP TRIGGER IF EXISTS update_sessions_updated_at;
2DROP TRIGGER IF EXISTS update_messages_updated_at;
2DROP TRIGGER IF EXISTS update_messages_updated_at;
3+DROP TRIGGER IF EXISTS update_files_updated_at;
3
4
4DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
5DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
5DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
6DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
6
7
7DROP TABLE IF EXISTS sessions;
8DROP TABLE IF EXISTS sessions;
8DROP TABLE IF EXISTS messages;
9DROP 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')
18WHERE id = new.id;
18WHERE id = new.id;
19END;
19END;
20
20
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+
21-- Messages
43-- Messages
22CREATE TABLE IF NOT EXISTS messages (
44CREATE TABLE IF NOT EXISTS messages (
23 id TEXT PRIMARY KEY,
45 id TEXT PRIMARY KEY,
internal/db/models.go+10
@@ -8,6 +8,16 @@ import (
8 "database/sql"
8 "database/sql"
9)
9)
10
10
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+
11type Message struct {
21type Message struct {
12 ID string `json:"id"`
22 ID string `json:"id"`
13 SessionID string `json:"session_id"`
23 SessionID string `json:"session_id"`
internal/db/querier.go+10
@@ -9,15 +9,25 @@ import (
9)
9)
10
10
11type Querier interface {
11type Querier interface {
12+ CreateFile(ctx context.Context, arg CreateFileParams) (File, error)
12 CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error)
13 CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error)
13 CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
14 CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
15+ DeleteFile(ctx context.Context, id string) error
14 DeleteMessage(ctx context.Context, id string) error
16 DeleteMessage(ctx context.Context, id string) error
15 DeleteSession(ctx context.Context, id string) error
17 DeleteSession(ctx context.Context, id string) error
18+ DeleteSessionFiles(ctx context.Context, sessionID string) error
16 DeleteSessionMessages(ctx context.Context, sessionID string) error
19 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)
17 GetMessage(ctx context.Context, id string) (Message, error)
22 GetMessage(ctx context.Context, id string) (Message, error)
18 GetSessionByID(ctx context.Context, id string) (Session, error)
23 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)
19 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
27 ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
28+ ListNewFiles(ctx context.Context) ([]File, error)
20 ListSessions(ctx context.Context) ([]Session, error)
29 ListSessions(ctx context.Context) ([]Session, error)
30+ UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error)
21 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
31 UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
22 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
32 UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
23}
33}
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) {
198 }
198 }
199 return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
199 return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
200 }
200 }
201− case key.Matches(msg, keys.Logs):
201+ // case key.Matches(msg, keys.Logs):
202− return a, a.moveToPage(page.LogsPage)
202+ // return a, a.moveToPage(page.LogsPage)
203 case msg.String() == "O":
203 case msg.String() == "O":
204 return a, a.moveToPage(page.ReplPage)
204 return a, a.moveToPage(page.ReplPage)
205 case key.Matches(msg, keys.Help):
205 case key.Matches(msg, keys.Help):