A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface

feat: uses github tree api to avoid rate limits

Adds in memory cache with 5 min TTL to avoid hitting github altogether. with a manual refresh button on the frontend

+561 -87
+5 -4
backend/go.mod
··· 5 5 require ( 6 6 github.com/go-chi/chi/v5 v5.2.4 7 7 github.com/go-chi/cors v1.2.2 8 + github.com/go-git/go-git/v5 v5.16.4 9 + github.com/google/go-github/v58 v58.0.0 8 10 github.com/gorilla/sessions v1.4.0 9 11 github.com/joho/godotenv v1.5.1 10 12 github.com/markbates/goth v1.82.0 13 + golang.org/x/oauth2 v0.27.0 14 + golang.org/x/sync v0.19.0 15 + gopkg.in/yaml.v3 v3.0.1 11 16 modernc.org/sqlite v1.44.3 12 17 ) 13 18 ··· 21 26 github.com/emirpasic/gods v1.18.1 // indirect 22 27 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 23 28 github.com/go-git/go-billy/v5 v5.6.2 // indirect 24 - github.com/go-git/go-git/v5 v5.16.4 // indirect 25 29 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 26 - github.com/google/go-github/v58 v58.0.0 // indirect 27 30 github.com/google/go-querystring v1.1.0 // indirect 28 31 github.com/google/uuid v1.6.0 // indirect 29 32 github.com/gorilla/mux v1.8.1 // indirect ··· 40 43 golang.org/x/crypto v0.37.0 // indirect 41 44 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect 42 45 golang.org/x/net v0.39.0 // indirect 43 - golang.org/x/oauth2 v0.27.0 // indirect 44 46 golang.org/x/sys v0.37.0 // indirect 45 47 gopkg.in/warnings.v0 v0.1.2 // indirect 46 - gopkg.in/yaml.v3 v3.0.1 // indirect 47 48 modernc.org/libc v1.67.6 // indirect 48 49 modernc.org/mathutil v1.7.1 // indirect 49 50 modernc.org/memory v1.11.0 // indirect
+29 -6
backend/go.sum
··· 5 5 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 6 github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= 7 7 github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 8 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 8 12 github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 9 13 github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 10 14 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= ··· 14 18 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 19 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 16 20 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 21 + github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 22 + github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 17 23 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 18 24 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 25 + github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 26 + github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 19 27 github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= 20 28 github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 21 29 github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= ··· 24 32 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 25 33 github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 26 34 github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 35 + github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 36 + github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 27 37 github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= 28 38 github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 29 39 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 30 40 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 31 41 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 32 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 33 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 34 42 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 43 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 35 44 github.com/google/go-github/v58 v58.0.0 h1:Una7GGERlF/37XfkPwpzYJe0Vp4dt2k1kCjlxwjIvzw= 36 45 github.com/google/go-github/v58 v58.0.0/go.mod h1:k4hxDKEfoWpSqFlc8LTpGd9fu2KrV1YAa6Hi6FmDNY4= 37 46 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= ··· 57 66 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 58 67 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 59 68 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 69 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 70 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 60 71 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 61 72 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 73 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 74 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 62 75 github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ= 63 76 github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk= 64 77 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 65 78 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 66 79 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 67 80 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 81 + github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 82 + github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 68 83 github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 69 84 github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 85 + github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 70 86 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 71 87 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 72 88 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 73 89 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 74 90 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 91 + github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 92 + github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 75 93 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 76 94 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 77 95 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= ··· 80 98 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 81 99 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 82 100 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 83 - github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 84 - github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 85 101 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 102 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 86 103 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 87 104 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 88 105 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= ··· 97 114 golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 98 115 golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 99 116 golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 100 - golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 101 - golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 117 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 118 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 102 119 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 103 120 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 121 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 109 126 golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 110 127 golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 111 128 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 129 + golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 130 + golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 112 131 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 132 + golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 133 + golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 113 134 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 114 135 golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 115 136 golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 116 137 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 117 138 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 118 139 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 140 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 141 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 119 142 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 120 143 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 121 144 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+44 -6
backend/internal/api/handlers/repos.go
··· 49 49 50 50 log.Printf("Listing repositories for user %d with token: %s...", userID, token.AccessToken[:min(10, len(token.AccessToken))]) 51 51 52 - // Create GitHub connector 53 - connector := connectors.NewGitHubConnector(token.AccessToken) 52 + // Create GitHub connector with DB and userID for cache logging 53 + connector := connectors.NewGitHubConnector(token.AccessToken, h.db, &userID) 54 54 55 55 // Get sort parameter 56 56 sortBy := r.URL.Query().Get("sort") ··· 107 107 return 108 108 } 109 109 110 - // Create GitHub connector 111 - connector := connectors.NewGitHubConnector(token.AccessToken) 110 + // Create GitHub connector with DB and userID for cache logging 111 + connector := connectors.NewGitHubConnector(token.AccessToken, h.db, &userID) 112 112 113 113 // Get query parameters 114 114 path := r.URL.Query().Get("path") ··· 169 169 return 170 170 } 171 171 172 - // Create GitHub connector 173 - connector := connectors.NewGitHubConnector(token.AccessToken) 172 + // Create GitHub connector with DB and userID for cache logging 173 + connector := connectors.NewGitHubConnector(token.AccessToken, h.db, &userID) 174 174 175 175 // Get branch parameter 176 176 branch := r.URL.Query().Get("branch") ··· 275 275 w.Header().Set("Content-Type", "application/json") 276 276 json.NewEncoder(w).Encode(response) 277 277 } 278 + 279 + // InvalidateCache clears file tree cache for a repository 280 + func (h *RepoHandler) InvalidateCache(w http.ResponseWriter, r *http.Request) { 281 + owner := chi.URLParam(r, "owner") 282 + repo := chi.URLParam(r, "repo") 283 + 284 + if owner == "" || repo == "" { 285 + http.Error(w, "Missing owner or repo parameter", http.StatusBadRequest) 286 + return 287 + } 288 + 289 + // Get user from session for logging 290 + session, err := auth.GetSession(r) 291 + if err != nil { 292 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 293 + return 294 + } 295 + 296 + userID, ok := auth.GetUserID(session) 297 + if !ok { 298 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 299 + return 300 + } 301 + 302 + // Invalidate all cache entries for this repo 303 + pattern := fmt.Sprintf("%s/%s/*", owner, repo) 304 + count := connectors.InvalidateCacheForRepo(owner, repo) 305 + 306 + // Log the invalidation 307 + if h.db != nil { 308 + _ = h.db.LogCacheEvent(pattern, "file_tree", "invalidate", &userID, 0) 309 + } 310 + 311 + w.Header().Set("Content-Type", "application/json") 312 + json.NewEncoder(w).Encode(map[string]interface{}{ 313 + "invalidated_count": count, 314 + }) 315 + }
+1
backend/internal/api/router.go
··· 68 68 r.Get("/api/repos/{owner}/{repo}/files", repoHandler.ListFiles) 69 69 r.Get("/api/repos/{owner}/{repo}/files/*", repoHandler.GetFileContent) 70 70 r.Put("/api/repos/{owner}/{repo}/files/*", repoHandler.UpdateFileContent) 71 + r.Delete("/api/repos/{owner}/{repo}/cache", repoHandler.InvalidateCache) 71 72 72 73 // Branch routes 73 74 r.Get("/api/repos/{owner}/{repo}/branch/status", branchHandler.GetBranchStatus)
+7 -6
backend/internal/connectors/connector.go
··· 18 18 19 19 // FileNode represents a file or directory 20 20 type FileNode struct { 21 - Path string `json:"path"` 22 - Name string `json:"name"` 23 - Type string `json:"type"` // "file" or "dir" 24 - Size int64 `json:"size,omitempty"` 25 - SHA string `json:"sha,omitempty"` 26 - Children []FileNode `json:"children,omitempty"` 21 + Path string `json:"path"` 22 + Name string `json:"name"` 23 + Type string `json:"type"` // "file" or "directory" 24 + IsDir bool `json:"is_dir"` 25 + Size int64 `json:"size,omitempty"` 26 + SHA string `json:"sha,omitempty"` 27 + Children []*FileNode `json:"children,omitempty"` 27 28 } 28 29 29 30 // FileContent represents the content of a file
+303 -65
backend/internal/connectors/github.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "os" 6 7 "path/filepath" 8 + "sort" 9 + "strconv" 7 10 "strings" 11 + "sync" 12 + "time" 8 13 9 14 "github.com/google/go-github/v58/github" 15 + "github.com/yourusername/markedit/internal/database" 10 16 "github.com/yourusername/markedit/internal/markdown" 11 17 "golang.org/x/oauth2" 18 + "golang.org/x/sync/singleflight" 12 19 ) 13 20 21 + // fileTreeCache is an in-memory LRU cache for file tree data 22 + type fileTreeCache struct { 23 + mu sync.RWMutex 24 + entries map[string]*cacheEntry 25 + maxSize int 26 + ttl time.Duration 27 + sfGroup singleflight.Group // Request deduplication 28 + } 29 + 30 + type cacheEntry struct { 31 + data *FileNode 32 + expiresAt time.Time 33 + etag string // For future ETag support 34 + fetchedAt time.Time 35 + } 36 + 37 + var globalCache = &fileTreeCache{ 38 + entries: make(map[string]*cacheEntry), 39 + maxSize: 100, // Configurable via env 40 + ttl: 5 * time.Minute, 41 + } 42 + 43 + func init() { 44 + // Configure cache from environment 45 + if ttlStr := os.Getenv("FILE_TREE_CACHE_TTL"); ttlStr != "" { 46 + if ttl, err := time.ParseDuration(ttlStr); err == nil { 47 + globalCache.ttl = ttl 48 + } 49 + } 50 + 51 + if sizeStr := os.Getenv("FILE_TREE_CACHE_SIZE"); sizeStr != "" { 52 + if size, err := strconv.Atoi(sizeStr); err == nil { 53 + globalCache.maxSize = size 54 + } 55 + } 56 + } 57 + 58 + // Get retrieves from cache if not expired 59 + func (c *fileTreeCache) Get(key string) (*FileNode, bool) { 60 + c.mu.RLock() 61 + defer c.mu.RUnlock() 62 + 63 + entry, exists := c.entries[key] 64 + if !exists || time.Now().After(entry.expiresAt) { 65 + return nil, false 66 + } 67 + return entry.data, true 68 + } 69 + 70 + // Set stores in cache with TTL 71 + func (c *fileTreeCache) Set(key string, data *FileNode) { 72 + c.mu.Lock() 73 + defer c.mu.Unlock() 74 + 75 + // LRU eviction if at capacity 76 + if len(c.entries) >= c.maxSize { 77 + c.evictOldest() 78 + } 79 + 80 + c.entries[key] = &cacheEntry{ 81 + data: data, 82 + expiresAt: time.Now().Add(c.ttl), 83 + fetchedAt: time.Now(), 84 + } 85 + } 86 + 87 + // Invalidate removes specific cache entry 88 + func (c *fileTreeCache) Invalidate(key string) { 89 + c.mu.Lock() 90 + defer c.mu.Unlock() 91 + delete(c.entries, key) 92 + } 93 + 94 + // InvalidatePattern removes all matching keys (e.g., "owner/repo/*") 95 + func (c *fileTreeCache) InvalidatePattern(pattern string) int { 96 + c.mu.Lock() 97 + defer c.mu.Unlock() 98 + 99 + count := 0 100 + for key := range c.entries { 101 + if matchesPattern(key, pattern) { 102 + delete(c.entries, key) 103 + count++ 104 + } 105 + } 106 + return count 107 + } 108 + 109 + // evictOldest removes the oldest entry (LRU) 110 + func (c *fileTreeCache) evictOldest() { 111 + var oldestKey string 112 + var oldestTime time.Time 113 + 114 + for key, entry := range c.entries { 115 + if oldestKey == "" || entry.fetchedAt.Before(oldestTime) { 116 + oldestKey = key 117 + oldestTime = entry.fetchedAt 118 + } 119 + } 120 + 121 + if oldestKey != "" { 122 + delete(c.entries, oldestKey) 123 + } 124 + } 125 + 126 + // generateCacheKey creates consistent cache key 127 + func generateCacheKey(owner, repo, branch, path string, extensions []string) string { 128 + extStr := strings.Join(extensions, ",") 129 + return fmt.Sprintf("%s/%s/%s/%s/%s", owner, repo, branch, path, extStr) 130 + } 131 + 132 + // matchesPattern checks if key matches pattern (simple glob) 133 + func matchesPattern(key, pattern string) bool { 134 + // Simple implementation: pattern can end with /* for prefix match 135 + if strings.HasSuffix(pattern, "/*") { 136 + prefix := strings.TrimSuffix(pattern, "/*") 137 + return strings.HasPrefix(key, prefix) 138 + } 139 + return key == pattern 140 + } 141 + 14 142 // GitHubConnector implements the Connector interface for GitHub 15 143 type GitHubConnector struct { 16 144 client *github.Client 145 + db *database.DB // For logging 146 + userID *int // For logging 17 147 } 18 148 19 149 // NewGitHubConnector creates a new GitHub connector with an access token 20 - func NewGitHubConnector(accessToken string) *GitHubConnector { 150 + func NewGitHubConnector(accessToken string, db *database.DB, userID *int) *GitHubConnector { 21 151 ctx := context.Background() 22 152 ts := oauth2.StaticTokenSource( 23 153 &oauth2.Token{AccessToken: accessToken}, ··· 27 157 28 158 return &GitHubConnector{ 29 159 client: client, 160 + db: db, 161 + userID: userID, 30 162 } 31 163 } 32 164 ··· 35 167 return "github" 36 168 } 37 169 170 + // logCacheEvent logs cache events to the database 171 + func (g *GitHubConnector) logCacheEvent(cacheKey, eventType string, responseTimeMs int) { 172 + if g.db != nil { 173 + _ = g.db.LogCacheEvent(cacheKey, "file_tree", eventType, g.userID, responseTimeMs) 174 + } 175 + } 176 + 38 177 // ListRepositories lists all repositories for the authenticated user 39 178 func (g *GitHubConnector) ListRepositories(ctx context.Context, sortBy string) ([]Repository, error) { 40 179 // Map sort parameter ··· 86 225 return allRepos, nil 87 226 } 88 227 89 - // ListFiles lists files in a repository path 228 + // ListFiles lists files in a repository path using GitHub Tree API with caching 90 229 func (g *GitHubConnector) ListFiles(ctx context.Context, owner, repo, path, branch string, extensions []string) (*FileNode, error) { 230 + // Resolve default branch first to ensure consistent cache keys 91 231 if branch == "" { 92 - // Get default branch 93 232 repository, _, err := g.client.Repositories.Get(ctx, owner, repo) 94 233 if err != nil { 95 234 return nil, fmt.Errorf("failed to get repository: %w", err) ··· 97 236 branch = repository.GetDefaultBranch() 98 237 } 99 238 100 - opts := &github.RepositoryContentGetOptions{ 101 - Ref: branch, 239 + cacheKey := generateCacheKey(owner, repo, branch, path, extensions) 240 + 241 + // Try cache first 242 + if cached, found := globalCache.Get(cacheKey); found { 243 + g.logCacheEvent(cacheKey, "hit", 0) 244 + return cached, nil 102 245 } 103 246 104 - _, dirContent, _, err := g.client.Repositories.GetContents(ctx, owner, repo, path, opts) 247 + // Use singleflight to deduplicate concurrent requests 248 + result, err, _ := globalCache.sfGroup.Do(cacheKey, func() (interface{}, error) { 249 + startTime := time.Now() 250 + 251 + // Fetch from GitHub Tree API 252 + data, err := g.fetchTreeFromGitHub(ctx, owner, repo, branch, path, extensions) 253 + if err != nil { 254 + return nil, err 255 + } 256 + 257 + responseTime := int(time.Since(startTime).Milliseconds()) 258 + 259 + // Store in cache 260 + globalCache.Set(cacheKey, data) 261 + g.logCacheEvent(cacheKey, "miss", responseTime) 262 + 263 + return data, nil 264 + }) 265 + 105 266 if err != nil { 106 - return nil, fmt.Errorf("failed to get contents: %w", err) 267 + return nil, err 107 268 } 269 + return result.(*FileNode), nil 270 + } 108 271 109 - root := &FileNode{ 110 - Path: path, 111 - Name: filepath.Base(path), 112 - Type: "dir", 113 - Children: []FileNode{}, 272 + // fetchTreeFromGitHub fetches file tree from GitHub using Tree API 273 + func (g *GitHubConnector) fetchTreeFromGitHub(ctx context.Context, owner, repo, branch, path string, extensions []string) (*FileNode, error) { 274 + // Step 1: Get branch to get commit SHA 275 + branchRef, _, err := g.client.Repositories.GetBranch(ctx, owner, repo, branch, 0) 276 + if err != nil { 277 + return nil, fmt.Errorf("failed to get branch: %w", err) 114 278 } 279 + commitSHA := branchRef.GetCommit().GetSHA() 115 280 116 - if path == "" { 117 - root.Name = repo 281 + // Step 2: Fetch tree recursively (single API call) 282 + tree, _, err := g.client.Git.GetTree(ctx, owner, repo, commitSHA, true) 283 + if err != nil { 284 + return nil, fmt.Errorf("failed to get tree: %w", err) 118 285 } 119 286 120 - for _, item := range dirContent { 121 - node := FileNode{ 122 - Path: item.GetPath(), 123 - Name: item.GetName(), 124 - Type: item.GetType(), 125 - Size: int64(item.GetSize()), 126 - SHA: item.GetSHA(), 127 - } 287 + // Check for truncated response 288 + if tree.Truncated != nil && *tree.Truncated { 289 + return nil, fmt.Errorf("repository tree is truncated (>100k entries) - repository too large") 290 + } 128 291 129 - // Filter by extension if specified 130 - if len(extensions) > 0 && node.Type == "file" { 131 - ext := strings.TrimPrefix(filepath.Ext(node.Name), ".") 132 - found := false 133 - for _, allowedExt := range extensions { 134 - if ext == allowedExt { 135 - found = true 136 - break 137 - } 138 - } 139 - if !found { 140 - continue 141 - } 292 + // Step 3: Filter tree entries by path and extensions 293 + var filteredEntries []*github.TreeEntry 294 + for _, entry := range tree.Entries { 295 + // Skip if not in requested path 296 + if path != "" && !strings.HasPrefix(*entry.Path, path) { 297 + continue 142 298 } 143 299 144 - // Recursively get directory contents 145 - if node.Type == "dir" { 146 - subNode, err := g.ListFiles(ctx, owner, repo, item.GetPath(), branch, extensions) 147 - if err != nil { 148 - // Log error but continue 149 - continue 150 - } 151 - node.Children = subNode.Children 152 - 153 - // Skip empty directories (directories with no matching files) 154 - if !hasMatchingFiles(&node, extensions) { 300 + // Filter by extensions (only for blobs/files) 301 + if len(extensions) > 0 && *entry.Type == "blob" { 302 + if !matchesExtensions(*entry.Path, extensions) { 155 303 continue 156 304 } 157 305 } 158 306 159 - root.Children = append(root.Children, node) 307 + filteredEntries = append(filteredEntries, entry) 160 308 } 161 309 310 + // Step 4: Build hierarchical FileNode tree from flat structure 311 + root := buildFileTree(filteredEntries, path, extensions, repo) 312 + 162 313 return root, nil 163 314 } 164 315 165 - // hasMatchingFiles checks if a directory node contains any files (recursively) 166 - func hasMatchingFiles(node *FileNode, extensions []string) bool { 167 - if node.Type == "file" { 168 - // If extensions are specified, check if file matches 169 - if len(extensions) > 0 { 170 - ext := strings.TrimPrefix(filepath.Ext(node.Name), ".") 171 - for _, allowedExt := range extensions { 172 - if ext == allowedExt { 173 - return true 174 - } 175 - } 176 - return false 316 + // matchesExtensions checks if file matches extension filter 317 + func matchesExtensions(filename string, extensions []string) bool { 318 + if len(extensions) == 0 { 319 + return true 320 + } 321 + 322 + ext := strings.TrimPrefix(filepath.Ext(filename), ".") 323 + for _, e := range extensions { 324 + if strings.EqualFold(ext, e) { 325 + return true 177 326 } 178 - return true 179 327 } 328 + return false 329 + } 180 330 181 - if node.Type == "dir" { 182 - for _, child := range node.Children { 183 - if hasMatchingFiles(&child, extensions) { 184 - return true 331 + // buildFileTree converts flat GitHub tree entries to hierarchical FileNode structure 332 + func buildFileTree(entries []*github.TreeEntry, rootPath string, extensions []string, repoName string) *FileNode { 333 + root := &FileNode{ 334 + Name: repoName, 335 + Path: rootPath, 336 + Type: "directory", 337 + IsDir: true, 338 + Children: []*FileNode{}, 339 + } 340 + 341 + if rootPath != "" { 342 + root.Name = filepath.Base(rootPath) 343 + } 344 + 345 + // Group entries by their first path component 346 + pathGroups := make(map[string][]*github.TreeEntry) 347 + 348 + for _, entry := range entries { 349 + relativePath := *entry.Path 350 + if rootPath != "" { 351 + relativePath = strings.TrimPrefix(relativePath, rootPath) 352 + relativePath = strings.TrimPrefix(relativePath, "/") 353 + } 354 + 355 + if relativePath == "" { 356 + continue 357 + } 358 + 359 + // Get first component 360 + parts := strings.SplitN(relativePath, "/", 2) 361 + firstComponent := parts[0] 362 + 363 + pathGroups[firstComponent] = append(pathGroups[firstComponent], entry) 364 + } 365 + 366 + // Build tree recursively 367 + for name, groupEntries := range pathGroups { 368 + // Check if this is a file or directory 369 + isFile := len(groupEntries) == 1 && *groupEntries[0].Type == "blob" && !strings.Contains(strings.TrimPrefix(*groupEntries[0].Path, rootPath+"/"), "/") 370 + 371 + if isFile { 372 + // It's a file 373 + filePath := *groupEntries[0].Path 374 + root.Children = append(root.Children, &FileNode{ 375 + Name: name, 376 + Path: filePath, 377 + Type: "file", 378 + IsDir: false, 379 + SHA: *groupEntries[0].SHA, 380 + Size: int64(*groupEntries[0].Size), 381 + }) 382 + } else { 383 + // It's a directory - collect all direct children 384 + dirNode := &FileNode{ 385 + Name: name, 386 + Path: filepath.Join(rootPath, name), 387 + Type: "directory", 388 + IsDir: true, 389 + Children: []*FileNode{}, 390 + } 391 + 392 + // Get direct children of this directory 393 + directChildren := make(map[string][]*github.TreeEntry) 394 + dirPrefix := dirNode.Path + "/" 395 + 396 + for _, entry := range groupEntries { 397 + relativeToDir := strings.TrimPrefix(*entry.Path, dirPrefix) 398 + parts := strings.SplitN(relativeToDir, "/", 2) 399 + directChildren[parts[0]] = append(directChildren[parts[0]], entry) 400 + } 401 + 402 + // Recursively build subtree 403 + subTree := buildFileTree(groupEntries, dirNode.Path, extensions, name) 404 + dirNode.Children = subTree.Children 405 + 406 + // Only add directory if it has children (matching files) 407 + if len(dirNode.Children) > 0 { 408 + root.Children = append(root.Children, dirNode) 185 409 } 186 410 } 187 411 } 188 412 189 - return false 413 + // Sort: directories first, then alphabetically 414 + sort.Slice(root.Children, func(i, j int) bool { 415 + if root.Children[i].IsDir != root.Children[j].IsDir { 416 + return root.Children[i].IsDir 417 + } 418 + return root.Children[i].Name < root.Children[j].Name 419 + }) 420 + 421 + return root 190 422 } 191 423 192 424 // GetFileContent retrieves the content of a file ··· 232 464 Branch: branch, 233 465 }, nil 234 466 } 467 + 468 + // InvalidateCacheForRepo invalidates all cache entries for a repository 469 + func InvalidateCacheForRepo(owner, repo string) int { 470 + pattern := fmt.Sprintf("%s/%s/*", owner, repo) 471 + return globalCache.InvalidatePattern(pattern) 472 + }
+23
backend/internal/database/migrations/003_cache_stats.sql
··· 1 + -- Cache statistics table 2 + CREATE TABLE IF NOT EXISTS cache_stats ( 3 + id INTEGER PRIMARY KEY AUTOINCREMENT, 4 + cache_key TEXT NOT NULL, 5 + cache_type TEXT NOT NULL, -- 'file_tree' 6 + event_type TEXT NOT NULL, -- 'hit', 'miss', 'invalidate' 7 + user_id INTEGER, 8 + response_time_ms INTEGER, 9 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 10 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL 11 + ); 12 + 13 + -- Index for efficient queries 14 + CREATE INDEX IF NOT EXISTS idx_cache_stats_created_at ON cache_stats(created_at); 15 + CREATE INDEX IF NOT EXISTS idx_cache_stats_type_event ON cache_stats(cache_type, event_type); 16 + 17 + -- Auto-cleanup trigger: delete logs older than 7 days 18 + CREATE TRIGGER IF NOT EXISTS cleanup_old_cache_stats 19 + AFTER INSERT ON cache_stats 20 + BEGIN 21 + DELETE FROM cache_stats 22 + WHERE created_at < datetime('now', '-7 days'); 23 + END;
+11
backend/internal/database/models.go
··· 59 59 Content string `json:"content"` 60 60 LastSavedAt time.Time `json:"last_saved_at"` 61 61 } 62 + 63 + // CacheStat represents a cache statistics entry 64 + type CacheStat struct { 65 + ID int `json:"id"` 66 + CacheKey string `json:"cache_key"` 67 + CacheType string `json:"cache_type"` 68 + EventType string `json:"event_type"` 69 + UserID *int `json:"user_id,omitempty"` 70 + ResponseTimeMs int `json:"response_time_ms"` 71 + CreatedAt time.Time `json:"created_at"` 72 + }
+56
backend/internal/database/queries.go
··· 373 373 374 374 return nil 375 375 } 376 + 377 + // LogCacheEvent logs a cache event to the database 378 + func (db *DB) LogCacheEvent(cacheKey, cacheType, eventType string, userID *int, responseTimeMs int) error { 379 + query := ` 380 + INSERT INTO cache_stats (cache_key, cache_type, event_type, user_id, response_time_ms) 381 + VALUES (?, ?, ?, ?, ?) 382 + ` 383 + _, err := db.Exec(query, cacheKey, cacheType, eventType, userID, responseTimeMs) 384 + return err 385 + } 386 + 387 + // GetCacheStats retrieves cache statistics since a given time 388 + func (db *DB) GetCacheStats(since time.Time) ([]CacheStat, error) { 389 + query := ` 390 + SELECT id, cache_key, cache_type, event_type, user_id, response_time_ms, created_at 391 + FROM cache_stats 392 + WHERE created_at >= ? 393 + ORDER BY created_at DESC 394 + LIMIT 1000 395 + ` 396 + rows, err := db.Query(query, since) 397 + if err != nil { 398 + return nil, err 399 + } 400 + defer rows.Close() 401 + 402 + var stats []CacheStat 403 + for rows.Next() { 404 + var s CacheStat 405 + err := rows.Scan(&s.ID, &s.CacheKey, &s.CacheType, &s.EventType, &s.UserID, &s.ResponseTimeMs, &s.CreatedAt) 406 + if err != nil { 407 + return nil, err 408 + } 409 + stats = append(stats, s) 410 + } 411 + return stats, nil 412 + } 413 + 414 + // GetCacheHitRate calculates the cache hit rate since a given time 415 + func (db *DB) GetCacheHitRate(since time.Time) (float64, error) { 416 + query := ` 417 + SELECT 418 + SUM(CASE WHEN event_type = 'hit' THEN 1 ELSE 0 END) as hits, 419 + COUNT(*) as total 420 + FROM cache_stats 421 + WHERE created_at >= ? AND event_type IN ('hit', 'miss') 422 + ` 423 + 424 + var hits, total int 425 + err := db.QueryRow(query, since).Scan(&hits, &total) 426 + if err != nil || total == 0 { 427 + return 0, err 428 + } 429 + 430 + return float64(hits) / float64(total) * 100, nil 431 + }
+9
frontend/src/components/dashboard/DashboardApp.tsx
··· 3 3 import { Toaster } from 'sonner'; 4 4 import { Header } from '../layout/Header'; 5 5 import { FileTree } from './FileTree'; 6 + import { FileTreeHeader } from './FileTreeHeader'; 6 7 import { EditorContainer } from '../editor/EditorContainer'; 7 8 import { useFiles } from '../../lib/hooks/useRepos'; 8 9 import { useCurrentUser, useUserRepos } from '../../lib/hooks/useAuth'; ··· 155 156 Change repository 156 157 </button> 157 158 </div> 159 + 160 + {/* File tree header with refresh button */} 161 + {repoConfig && ( 162 + <FileTreeHeader 163 + owner={repoConfig.owner} 164 + repo={repoConfig.repo} 165 + /> 166 + )} 158 167 159 168 {filesLoading ? ( 160 169 <div className="p-4">
+63
frontend/src/components/dashboard/FileTreeHeader.tsx
··· 1 + import { useState } from 'react'; 2 + import { useQueryClient } from '@tanstack/react-query'; 3 + import { reposApi } from '../../lib/api/repos'; 4 + import { toast } from 'sonner'; 5 + 6 + interface FileTreeHeaderProps { 7 + owner: string; 8 + repo: string; 9 + } 10 + 11 + export function FileTreeHeader({ owner, repo }: FileTreeHeaderProps) { 12 + const [isRefreshing, setIsRefreshing] = useState(false); 13 + const queryClient = useQueryClient(); 14 + 15 + const handleRefresh = async () => { 16 + setIsRefreshing(true); 17 + 18 + try { 19 + // Invalidate backend cache 20 + const result = await reposApi.invalidateCache(owner, repo); 21 + 22 + // Invalidate React Query cache 23 + await queryClient.invalidateQueries({ 24 + queryKey: ['files', owner, repo], 25 + refetchType: 'all', // Force refetch even for inactive queries 26 + }); 27 + 28 + toast.success(`Cache cleared! ${result.invalidated_count} entries invalidated.`); 29 + } catch (error) { 30 + console.error('Failed to refresh cache:', error); 31 + toast.error('Failed to refresh cache. Please try again.'); 32 + } finally { 33 + setIsRefreshing(false); 34 + } 35 + }; 36 + 37 + return ( 38 + <div className="p-4 border-b border-gray-200 flex items-center justify-between"> 39 + <div className="text-sm font-semibold text-gray-900">Files</div> 40 + <button 41 + onClick={handleRefresh} 42 + disabled={isRefreshing} 43 + className="text-xs px-3 py-1.5 rounded-md border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 transition-colors" 44 + title="Refresh file list (clears cache)" 45 + > 46 + <svg 47 + className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} 48 + fill="none" 49 + viewBox="0 0 24 24" 50 + stroke="currentColor" 51 + > 52 + <path 53 + strokeLinecap="round" 54 + strokeLinejoin="round" 55 + strokeWidth={2} 56 + d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" 57 + /> 58 + </svg> 59 + <span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span> 60 + </button> 61 + </div> 62 + ); 63 + }
+5
frontend/src/lib/api/repos.ts
··· 40 40 }); 41 41 return data; 42 42 }, 43 + 44 + invalidateCache: async (owner: string, repo: string): Promise<{ invalidated_count: number }> => { 45 + const { data } = await apiClient.delete<{ invalidated_count: number }>(`/api/repos/${owner}/${repo}/cache`); 46 + return data; 47 + }, 43 48 };
+5
frontend/src/lib/hooks/useRepos.ts
··· 19 19 queryKey: ['files', owner, repo, path, branch, extensions], 20 20 queryFn: () => reposApi.listFiles(owner, repo, path, branch, extensions), 21 21 enabled: !!owner && !!repo, 22 + // Extended cache configuration for performance 23 + staleTime: 5 * 60 * 1000, // 5 min - aligns with backend cache 24 + gcTime: 10 * 60 * 1000, // 10 min - keep in cache longer 25 + refetchOnMount: false, // Don't refetch if data is fresh 26 + refetchOnReconnect: false, // Don't refetch on reconnect 22 27 }); 23 28 } 24 29