Skip to content

Commit fd44e6b

Browse files
feat: add file tree repository view
Signed-off-by: roman-kiselenko <roman.kiselenko.dev@gmail.com>
1 parent 80faf3b commit fd44e6b

File tree

9 files changed

+266
-173
lines changed

9 files changed

+266
-173
lines changed

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@
2828

2929
### Preview
3030

31-
<p align="center">
32-
<img src="assets/web_1.png" alt="screenshot" width="700" />
33-
</p>
31+
<img src="assets/dark.png" alt="screenshot" width="45%" />
32+
<img src="assets/light.png" alt="screenshot" width="45%" />
3433

3534
### Getting Started
3635

assets/dark.png

271 KB
Loading

assets/light.png

290 KB
Loading

assets/web_1.png

-209 KB
Binary file not shown.

frontend/src/components/views/RepoViewPage.tsx

Lines changed: 24 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -12,65 +12,30 @@ import columns from '@/components/views/ColumnDef';
1212
import { Input } from '@/components/ui/input';
1313
import { useAuth } from '@/context/AuthProvider';
1414

15-
type FileTreeItem = { name: string } | { name: string; items: FileTreeItem[] };
15+
// type FileTreeItem = { name: string } | { name: string; items: FileTreeItem[] };
1616

1717
export function RepoViewPage() {
1818
const { user, repoPath } = useParams<{ user: string; repoPath: string }>();
1919
const repos = useReposState();
2020
const repoFiles = useRepoFilesState();
2121
const [searchQuery, setSearchQuery] = useState('');
2222
const { logout, AuthDisabled } = useAuth();
23-
const fileTree: FileTreeItem[] = [
24-
{
25-
name: 'components',
26-
items: [
27-
{
28-
name: 'ui',
29-
items: [
30-
{ name: 'button.tsx' },
31-
{ name: 'card.tsx' },
32-
{ name: 'dialog.tsx' },
33-
{ name: 'input.tsx' },
34-
{ name: 'select.tsx' },
35-
{ name: 'table.tsx' },
36-
],
37-
},
38-
{ name: 'login-form.tsx' },
39-
{ name: 'register-form.tsx' },
40-
],
41-
},
42-
{
43-
name: 'lib',
44-
items: [{ name: 'utils.ts' }, { name: 'cn.ts' }, { name: 'api.ts' }],
45-
},
46-
{
47-
name: 'hooks',
48-
items: [
49-
{ name: 'use-media-query.ts' },
50-
{ name: 'use-debounce.ts' },
51-
{ name: 'use-local-storage.ts' },
52-
],
53-
},
54-
{
55-
name: 'types',
56-
items: [{ name: 'index.d.ts' }, { name: 'api.d.ts' }],
57-
},
58-
{
59-
name: 'public',
60-
items: [{ name: 'favicon.ico' }, { name: 'logo.svg' }, { name: 'images' }],
61-
},
62-
{ name: 'app.tsx' },
63-
{ name: 'layout.tsx' },
64-
{ name: 'globals.css' },
65-
{ name: 'package.json' },
66-
{ name: 'tsconfig.json' },
67-
{ name: 'README.md' },
68-
{ name: '.gitignore' },
69-
];
23+
24+
useEffect(() => {
25+
if (!repoPath) return;
26+
if (!repos.repos.get().length) {
27+
getRepos('');
28+
}
29+
}, [repoPath]);
30+
31+
useEffect(() => {
32+
if (!user || !repoPath) return;
33+
getRepoFiles(user, repoPath);
34+
}, [user]);
7035

7136
return (
7237
<div className="flex-grow overflow-auto">
73-
<div className="flex flex-row py-2 px-2 items-center justify-between">
38+
<div className="flex flex-row py-2 px-2 items-center justify-between mx-3">
7439
<Input
7540
placeholder="Filter by name..."
7641
className="placeholder:text-muted-foreground flex h-6 w-full rounded-md bg-transparent py-2 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
@@ -83,23 +48,24 @@ export function RepoViewPage() {
8348
)}
8449
</div>
8550

51+
<div className="flex flex-row py-2 px-2 items-center justify-between mx-3">
52+
<div className="text-sm">Author: {repoFiles.author.get()}</div>
53+
<div className="text-sm">Email: {repoFiles.email.get()}</div>
54+
<div className="text-sm">Date: {repoFiles.date.get()}</div>
55+
<div className="text-sm">Hash: {repoFiles.hash.get()}</div>
56+
</div>
8657
<div className="grid grid-cols-1">
8758
<div className="mx-3">
88-
<div className="flex flex-col gap-1">{fileTree.map((item) => renderItem(item))}</div>
89-
{/* <DataTable
90-
menuDisabled={true}
91-
kind="repo"
92-
noResult={false}
93-
columns={columns as any}
94-
data={repoFiles.files.get() as any}
95-
/> */}
59+
<div className="flex flex-col gap-1">
60+
{(repoFiles.files.get() || []).map((item) => renderItem(item))}
61+
</div>
9662
</div>
9763
</div>
9864
</div>
9965
);
10066
}
10167

102-
const renderItem = (fileItem: FileTreeItem) => {
68+
const renderItem = (fileItem: any) => {
10369
if ('items' in fileItem) {
10470
return (
10571
<Collapsible key={fileItem.name}>

frontend/src/store/repofiles.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,33 @@ import { hookstate, useHookstate } from '@hookstate/core';
22
import { toast } from 'sonner';
33
import { call } from '@/lib/api';
44

5-
export const repoFilesState = hookstate<{ files: object[] }>({
5+
export const repoFilesState = hookstate<{
6+
files: object[];
7+
hash: '';
8+
message: '';
9+
author: '';
10+
email: '';
11+
date: '';
12+
}>({
613
files: [],
14+
hash: '',
15+
message: '',
16+
author: '',
17+
email: '',
18+
date: '',
719
});
820

9-
export async function getRepoFiles(query: string, userName: string | undefined, repoName: string | undefined) {
21+
export async function getRepoFiles(userName: string | undefined, repoName: string | undefined) {
1022
try {
11-
let { files } = await call<any[]>(`repos/files/${userName}/${repoName}`);
12-
if (query !== '') {
13-
files = files.filter((c) => {
14-
return String(c.filename || '')
15-
.toLowerCase()
16-
.includes(query.toLowerCase());
17-
});
18-
}
19-
repoFilesState.files.set(files);
23+
let response = await call<any[]>(`repos/files/${userName}/${repoName}`);
24+
repoFilesState.set({
25+
files: response.files,
26+
hash: response.hash,
27+
message: response.message,
28+
author: response.author,
29+
email: response.email,
30+
date: response.date,
31+
});
2032
} catch (error: any) {
2133
toast.error('Error! Cant load files\n' + error.message);
2234
console.error('Error! Cant load files\n' + error.message);

pkg/route/files.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package route
2+
3+
import (
4+
"log/slog"
5+
"net/http"
6+
"strings"
7+
8+
"smolgit/pkg/model"
9+
10+
"github.com/gin-gonic/gin"
11+
gogit "github.com/go-git/go-git/v5"
12+
"github.com/go-git/go-git/v5/plumbing/object"
13+
)
14+
15+
// type Node struct {
16+
// Name string `json:"filename"`
17+
// IsDir bool `json:"is_dir"`
18+
// Children map[string]*Node `json:"children"`
19+
// Path string `json:"path"`
20+
// }
21+
22+
type Node struct {
23+
Name string `json:"name,omitempty"`
24+
Items []Node `json:"items,omitempty"`
25+
}
26+
27+
// buildFileTree constructs the hierarchical tree from a list of file paths
28+
func buildFileTree(paths []string) *Node {
29+
root := &Node{
30+
Name: "",
31+
Items: []Node{},
32+
}
33+
34+
for _, path := range paths {
35+
isDir := strings.HasSuffix(path, "/")
36+
path = strings.TrimSuffix(path, "/") // Remove trailing slash
37+
38+
parts := strings.Split(path, "/")
39+
current := root
40+
41+
// Traverse through the directory structure
42+
for i := 0; i < len(parts)-1; i++ {
43+
part := parts[i]
44+
found := false
45+
46+
// Check if a child with this name already exists
47+
for j := range current.Items {
48+
if current.Items[j].Name == part {
49+
current = &current.Items[j]
50+
found = true
51+
break
52+
}
53+
}
54+
55+
if !found {
56+
// Create a new directory node
57+
newNode := &Node{
58+
Name: part,
59+
Items: []Node{},
60+
}
61+
current.Items = append(current.Items, *newNode)
62+
current = newNode
63+
}
64+
}
65+
66+
// Handle the final part (file or directory)
67+
lastPart := parts[len(parts)-1]
68+
if isDir {
69+
// Add a new directory node
70+
newNode := &Node{
71+
Name: lastPart,
72+
Items: []Node{},
73+
}
74+
current.Items = append(current.Items, *newNode)
75+
} else {
76+
// Add a file node
77+
current.Items = append(current.Items, Node{Name: lastPart})
78+
}
79+
}
80+
81+
return root
82+
}
83+
84+
// collectFlatNodes returns a flat list of nodes, with each directory containing its items
85+
func collectFlatNodes(node *Node) []Node {
86+
var result []Node
87+
88+
// Function to recursively traverse the tree
89+
var traverse func(*Node)
90+
traverse = func(n *Node) {
91+
// Add current node to the result
92+
result = append(result, Node{Name: n.Name, Items: n.Items})
93+
94+
// If this node has children, recursively process them
95+
for _, child := range n.Items {
96+
traverse(&child)
97+
}
98+
}
99+
100+
traverse(node)
101+
return result
102+
}
103+
104+
func (r *Route) Files(c *gin.Context) {
105+
user, repoPath := c.Param("user"), c.Param("path")
106+
fullPath := "/" + user + "/" + repoPath
107+
// TODO more secure way to open repo
108+
// check user permissions
109+
slog.Debug("open repository", "fullpath", r.cfg.GitPath+fullPath)
110+
repo, err := gogit.PlainOpen(r.cfg.GitPath + fullPath)
111+
if err != nil {
112+
slog.Error("cant find repository", "err", err)
113+
c.JSON(http.StatusNotFound, gin.H{"title": "cant find any repository"})
114+
return
115+
}
116+
headRef, err := repo.Head()
117+
if err != nil {
118+
slog.Error("cant get repo HEAD", "err", err)
119+
c.JSON(http.StatusNotFound, gin.H{"title": "cant get repo HEAD"})
120+
return
121+
}
122+
lastCommit, err := repo.CommitObject(headRef.Hash())
123+
if err != nil {
124+
slog.Error("cant get commit object", "err", err)
125+
c.JSON(http.StatusNotFound, gin.H{"title": "cant get commit object"})
126+
return
127+
}
128+
files, err := lastCommit.Files()
129+
if err != nil {
130+
slog.Error("cant get commit files", "err", err)
131+
c.JSON(http.StatusNotFound, gin.H{"title": "cant get commit files"})
132+
return
133+
}
134+
135+
// Build file tree
136+
paths := []string{}
137+
if err := files.ForEach(func(f *object.File) error {
138+
paths = append(paths, f.Name)
139+
return nil
140+
}); err != nil {
141+
slog.Error("repo files", "err", err)
142+
c.JSON(http.StatusNotFound, gin.H{"title": repoPath + " not found"})
143+
return
144+
}
145+
root := buildFileTree(paths)
146+
147+
c.JSON(http.StatusOK, gin.H{
148+
"hash": lastCommit.Hash.String(),
149+
"message": lastCommit.Message,
150+
"author": lastCommit.Author.Name,
151+
"email": lastCommit.Author.Email,
152+
"date": lastCommit.Author.When,
153+
"title": "Files",
154+
"repo": model.Repository{Path: repoPath, User: &model.User{Name: user}},
155+
"files": root.Items,
156+
})
157+
}

pkg/route/login.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package route
2+
3+
import (
4+
"log/slog"
5+
"net/http"
6+
"slices"
7+
"time"
8+
9+
"smolgit/pkg/model"
10+
11+
"github.com/gin-gonic/gin"
12+
"github.com/golang-jwt/jwt/v5"
13+
"golang.org/x/crypto/bcrypt"
14+
)
15+
16+
type creds struct {
17+
Username string `json:"username"`
18+
Password string `json:"password"`
19+
}
20+
21+
func (r *Route) Login(c *gin.Context) {
22+
var req creds
23+
if err := c.ShouldBindJSON(&req); err != nil {
24+
slog.Error("parsing", "err", err.Error())
25+
c.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
26+
return
27+
}
28+
index := slices.IndexFunc(r.users, func(u model.User) bool {
29+
return u.Name == req.Username
30+
})
31+
if index == -1 {
32+
slog.Error("no such user", "user", req.Username)
33+
c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid credentials"})
34+
return
35+
}
36+
u := r.users[index]
37+
if bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)) != nil {
38+
slog.Error("bad password", "user", req.Username, "password", req.Password)
39+
c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid credentials"})
40+
return
41+
}
42+
43+
exp := time.Now().Add(1 * time.Hour)
44+
claims := &model.Claims{
45+
Username: u.Name,
46+
Role: u.Role,
47+
RegisteredClaims: jwt.RegisteredClaims{
48+
ExpiresAt: jwt.NewNumericDate(exp),
49+
},
50+
}
51+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
52+
t, err := token.SignedString([]byte(r.cfg.ServerJWTKey))
53+
if err != nil {
54+
slog.Error("cant sign token", "user", req.Username, "password", req.Password)
55+
c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid credentials"})
56+
return
57+
}
58+
59+
c.JSON(http.StatusOK, gin.H{"token": t})
60+
}

0 commit comments

Comments
 (0)