diff --git a/Makefile b/Makefile
index c3e66ea..8f4068c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
# Binary name
BINARY_NAME=lazyprisma
-VERSION ?= 0.2.2
+VERSION ?= 0.3.0
# Directories
BUILD_DIR=build
diff --git a/README.md b/README.md
index ad8c048..17c0501 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
[](https://deepwiki.com/DokaDev/lazyprisma)
+[](https://prisma.io)
A Terminal UI tool for managing Prisma migrations and the database, designed for developers who prefer the command line.
diff --git a/go.mod b/go.mod
index c528d00..cd98ca7 100644
--- a/go.mod
+++ b/go.mod
@@ -3,26 +3,26 @@ module github.com/dokadev/lazyprisma
go 1.25.5
require (
- github.com/jesseduffield/gocui v0.3.1-0.20251002151855-67e0e55ff42a
+ dario.cat/mergo v1.0.2
+ github.com/alecthomas/chroma/v2 v2.21.1
+ github.com/go-sql-driver/mysql v1.9.3
+ github.com/jesseduffield/gocui v0.3.1-0.20260128194906-9d8c3cdfac18
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
+ github.com/lib/pq v1.10.9
+ gopkg.in/yaml.v3 v3.0.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
- github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
- github.com/gdamore/tcell/v2 v2.8.0 // indirect
+ github.com/gdamore/tcell/v2 v2.13.5 // indirect
github.com/go-errors/errors v1.0.2 // indirect
- github.com/go-sql-driver/mysql v1.9.3 // indirect
- github.com/lib/pq v1.10.9 // indirect
- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.31.0 // indirect
golang.org/x/exp v0.0.0-20220317015231-48e79f11773a // indirect
- golang.org/x/sys v0.29.0 // indirect
- golang.org/x/term v0.28.0 // indirect
- golang.org/x/text v0.21.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
+ golang.org/x/sys v0.39.0 // indirect
+ golang.org/x/term v0.38.0 // indirect
+ golang.org/x/text v0.32.0 // indirect
)
diff --git a/go.sum b/go.sum
index e4ee263..3af77b7 100644
--- a/go.sum
+++ b/go.sum
@@ -1,41 +1,45 @@
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
-github.com/gdamore/tcell/v2 v2.8.0 h1:IDclow1j6kKpU/gOhjmc+7Pj5Dxnukb74pfKN4Cxrfg=
-github.com/gdamore/tcell/v2 v2.8.0/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
+github.com/gdamore/tcell/v2 v2.13.5 h1:YvWYCSr6gr2Ovs84dXbZLjDuOfQchhj8buOEqY52rpA=
+github.com/gdamore/tcell/v2 v2.13.5/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/go-errors/errors v1.0.2 h1:xMxH9j2fNg/L4hLn/4y3M0IUsn0M6Wbu/Uh9QlOfBh4=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/jesseduffield/gocui v0.3.1-0.20251002151855-67e0e55ff42a h1:z3NQvFXAWGT4B/MwQBZc+1ej8WJ/Nv35xngQRvwzPuI=
-github.com/jesseduffield/gocui v0.3.1-0.20251002151855-67e0e55ff42a/go.mod h1:sLIyZ2J42R6idGdtemZzsiR3xY5EF0KsvYEGh3dQv3s=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/jesseduffield/gocui v0.3.1-0.20260128194906-9d8c3cdfac18 h1:+Q17GqvNaGzuvIR1JCdIS0khVjMzdwrhXBBDxnsdN8Y=
+github.com/jesseduffield/gocui v0.3.1-0.20260128194906-9d8c3cdfac18/go.mod h1:lQCd2TvvNXVKFBowy4A7xxZbUp+1KEiGs4j0Q5Zt9gQ=
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY=
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5/go.mod h1:qxN4mHOAyeIDLP7IK7defgPClM/z1Kze8VVQiaEjzsQ=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
-github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.31.0 h1:Sfa+/064Tdo4SvlohQUQzBhgSer9v/coGvKQI/XLWAM=
github.com/samber/lo v1.31.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M=
@@ -43,71 +47,44 @@ github.com/thoas/go-funk v0.9.1/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xz
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20220317015231-48e79f11773a h1:DAzrdbxsb5tXNOhMCSwF7ZdfMbW46hE9fSVO6BsmUZM=
golang.org/x/exp v0.0.0-20220317015231-48e79f11773a/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
-golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
-golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
-golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
-golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
+golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
+golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
index 65bfbe1..bc28b21 100644
--- a/main.go
+++ b/main.go
@@ -5,6 +5,9 @@ import (
"os"
"github.com/dokadev/lazyprisma/pkg/app"
+ "github.com/dokadev/lazyprisma/pkg/config"
+ "github.com/dokadev/lazyprisma/pkg/gui/context"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
"github.com/dokadev/lazyprisma/pkg/prisma"
// Register database drivers
@@ -12,15 +15,18 @@ import (
)
const (
- Version = "v0.2.2"
+ Version = "v0.3.0"
Developer = "DokaLab"
)
func main() {
+ cfg, _ := config.Load()
+ tr := i18n.NewTranslationSet(cfg.Language)
+
// Handle version flag
if len(os.Args) > 1 {
if os.Args[1] == "--version" || os.Args[1] == "-v" {
- fmt.Printf("LazyPrisma %s (%s)\n", Version, Developer)
+ fmt.Printf(tr.VersionOutput, Version, Developer)
os.Exit(0)
}
}
@@ -28,59 +34,108 @@ func main() {
// Check if current directory is a Prisma workspace
cwd, err := os.Getwd()
if err != nil {
- fmt.Fprintf(os.Stderr, "Error: Failed to get current directory: %v\n", err)
+ fmt.Fprintf(os.Stderr, tr.ErrorFailedGetCurrentDir, err)
os.Exit(1)
}
if !prisma.IsWorkspace(cwd) {
- fmt.Fprintf(os.Stderr, "Error: Current directory is not a Prisma workspace.\n")
- fmt.Fprintf(os.Stderr, "\nExpected one of:\n")
- fmt.Fprintf(os.Stderr, " - prisma.config.ts (Prisma v7.0+)\n")
- fmt.Fprintf(os.Stderr, " - prisma/schema.prisma (Prisma < v7.0)\n")
+ fmt.Fprint(os.Stderr, tr.ErrorNotPrismaWorkspace)
+ fmt.Fprint(os.Stderr, tr.ErrorExpectedOneOf)
+ fmt.Fprint(os.Stderr, tr.ErrorExpectedConfigV7Plus)
+ fmt.Fprint(os.Stderr, tr.ErrorExpectedSchemaV7Minus)
os.Exit(1)
}
- // App 생성
+ // Create app
tuiApp, err := app.NewApp(app.AppConfig{
DebugMode: false,
AppName: "LazyPrisma",
Version: Version,
Developer: Developer,
+ Language: cfg.Language,
})
if err != nil {
- fmt.Fprintf(os.Stderr, "Failed to create app: %v\n", err)
+ fmt.Fprintf(os.Stderr, tr.ErrorFailedCreateApp, err)
os.Exit(1)
}
- // 패널 생성 및 등록
- workspace := app.NewWorkspacePanel(tuiApp.GetGui())
- migrations := app.NewMigrationsPanel(tuiApp.GetGui())
- details := app.NewDetailsPanel(tuiApp.GetGui())
- output := app.NewOutputPanel(tuiApp.GetGui())
- statusbar := app.NewStatusBar(tuiApp.GetGui(), tuiApp)
+ // Create and register panels
+ workspace := context.NewWorkspaceContext(context.WorkspaceContextOpts{
+ Gui: tuiApp.GetGui(),
+ Tr: tr,
+ ViewName: "workspace",
+ })
+ migrationsCtx := context.NewMigrationsContext(context.MigrationsContextOpts{
+ Gui: tuiApp.GetGui(),
+ Tr: tr,
+ ViewName: "migrations",
+ })
+ detailsCtx := context.NewDetailsContext(context.DetailsContextOpts{
+ Gui: tuiApp.GetGui(),
+ Tr: tr,
+ ViewName: "details",
+ })
+ output := context.NewOutputContext(context.OutputContextOpts{
+ Gui: tuiApp.GetGui(),
+ Tr: tr,
+ ViewName: "outputs",
+ })
+ statusbar := context.NewStatusBarContext(context.StatusBarContextOpts{
+ Gui: tuiApp.GetGui(),
+ Tr: tr,
+ ViewName: "statusbar",
+ State: tuiApp.StatusBarState(),
+ Config: context.StatusBarConfig{
+ Developer: Developer,
+ Version: Version,
+ },
+ })
+
+ // Wire callbacks (replaces old bidirectional coupling)
+ migrationsCtx.SetOnSelectionChanged(func(mig *prisma.Migration, tab string) {
+ detailsCtx.UpdateFromMigration(mig, tab)
+ })
+ migrationsCtx.SetModalCallbacks(tuiApp.HasActiveModal, func(viewID string) {
+ tuiApp.HandlePanelClick(viewID)
+ })
+ detailsCtx.SetModalCallbacks(tuiApp.HasActiveModal, func(viewID string) {
+ tuiApp.HandlePanelClick(viewID)
+ })
- // Connect panels
- migrations.SetDetailsPanel(details)
- details.SetApp(tuiApp)
+ // Load action-needed data for details context
+ detailsCtx.SetActionNeededMigrations(collectActionNeededMigrations(migrationsCtx.GetCategory()))
+ detailsCtx.LoadActionNeededData()
tuiApp.RegisterPanel(workspace)
- tuiApp.RegisterPanel(migrations)
- tuiApp.RegisterPanel(details)
+ tuiApp.RegisterPanel(migrationsCtx)
+ tuiApp.RegisterPanel(detailsCtx)
tuiApp.RegisterPanel(output)
tuiApp.RegisterPanel(statusbar)
- // 키바인딩 등록
+ // Register keybindings
if err := tuiApp.RegisterKeybindings(); err != nil {
- fmt.Fprintf(os.Stderr, "Failed to register keybindings: %v\n", err)
+ fmt.Fprintf(os.Stderr, tr.ErrorFailedRegisterKeybindings, err)
os.Exit(1)
}
- // 마우스 바인딩 등록
+ // Register mouse bindings
tuiApp.RegisterMouseBindings()
- // 실행
+ // Run
if err := tuiApp.Run(); err != nil {
- fmt.Fprintf(os.Stderr, "App error: %v\n", err)
+ fmt.Fprintf(os.Stderr, tr.ErrorAppRuntime, err)
os.Exit(1)
}
}
+
+// collectActionNeededMigrations extracts migrations that need action (Empty or Checksum Mismatch)
+// from the Local category.
+func collectActionNeededMigrations(cat prisma.MigrationCategory) []prisma.Migration {
+ var result []prisma.Migration
+ for _, mig := range cat.Local {
+ if mig.IsEmpty || mig.ChecksumMismatch {
+ result = append(result, mig)
+ }
+ }
+ return result
+}
diff --git a/pkg/app/app.go b/pkg/app/app.go
index a094d42..c52808c 100644
--- a/pkg/app/app.go
+++ b/pkg/app/app.go
@@ -6,6 +6,10 @@ import (
"time"
"github.com/dokadev/lazyprisma/pkg/commands"
+ "github.com/dokadev/lazyprisma/pkg/common"
+ "github.com/dokadev/lazyprisma/pkg/gui/context"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
+ "github.com/dokadev/lazyprisma/pkg/prisma"
"github.com/jesseduffield/gocui"
)
@@ -13,11 +17,11 @@ const (
spinnerTickInterval = 50 * time.Millisecond
)
-var spinnerFrames = []rune{'|', '/', '-', '\\'}
-
type App struct {
g *gocui.Gui
config AppConfig
+ Common *common.Common
+ Tr *i18n.TranslationSet
panels map[string]Panel
focusOrder []string
currentFocus int
@@ -42,6 +46,7 @@ type AppConfig struct {
AppName string
Version string
Developer string
+ Language string
}
func NewApp(config AppConfig) (*App, error) {
@@ -50,9 +55,13 @@ func NewApp(config AppConfig) (*App, error) {
return nil, err
}
+ cmn := common.NewCommon(i18n.NewTranslationSet(config.Language))
+
app := &App{
g: g,
config: config,
+ Common: cmn,
+ Tr: cmn.Tr,
panels: make(map[string]Panel),
focusOrder: []string{ViewWorkspace, ViewMigrations, ViewDetails, ViewOutputs},
currentFocus: 0,
@@ -79,7 +88,7 @@ func (a *App) Run() error {
}
}()
- // 초기 포커스
+ // Initial focus
if len(a.focusOrder) > 0 {
if panel, ok := a.panels[a.focusOrder[0]]; ok {
panel.OnFocus()
@@ -100,6 +109,27 @@ func (a *App) GetGui() *gocui.Gui {
return a.g
}
+// StatusBarState returns callbacks for the StatusBarContext to access App state.
+func (a *App) StatusBarState() context.StatusBarState {
+ return context.StatusBarState{
+ IsCommandRunning: func() bool {
+ return a.commandRunning.Load()
+ },
+ GetSpinnerFrame: func() uint32 {
+ return a.spinnerFrame.Load()
+ },
+ IsStudioRunning: func() bool {
+ return a.studioRunning
+ },
+ GetCommandName: func() string {
+ if val := a.runningCommandName.Load(); val != nil {
+ return val.(string)
+ }
+ return ""
+ },
+ }
+}
+
// OpenModal opens a modal and saves current focus state
func (a *App) OpenModal(modal Modal) {
// Save current focus
@@ -165,18 +195,18 @@ func (a *App) finishCommand() {
// logCommandBlocked logs a message when command execution is blocked
func (a *App) logCommandBlocked(commandName string) {
a.g.Update(func(g *gocui.Gui) error {
- if outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
+ if outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
runningTask := ""
if val := a.runningCommandName.Load(); val != nil {
runningTask = val.(string)
}
- message := fmt.Sprintf("Cannot execute '%s'", commandName)
+ message := fmt.Sprintf(a.Tr.ErrorCannotExecuteCommand, commandName)
if runningTask != "" {
- message += fmt.Sprintf(" ('%s' is currently running)", runningTask)
+ message += fmt.Sprintf(a.Tr.ErrorCommandCurrentlyRunning, runningTask)
}
- outputPanel.LogActionRed("Operation Blocked", message)
+ outputPanel.LogActionRed(a.Tr.ErrorOperationBlocked, message)
}
return nil
})
@@ -195,7 +225,7 @@ func (a *App) startSpinnerUpdater() {
if a.commandRunning.Load() {
// Increment frame index (0-3, wrapping around)
currentFrame := a.spinnerFrame.Load()
- nextFrame := (currentFrame + 1) % uint32(len(spinnerFrames))
+ nextFrame := (currentFrame + 1) % context.SpinnerFrameCount()
a.spinnerFrame.Store(nextFrame)
// Trigger UI update (thread-safe)
@@ -211,6 +241,12 @@ func (a *App) startSpinnerUpdater() {
}()
}
+// HandlePanelClick is the public wrapper for panel-click focus switching.
+// It is used as a callback by contexts that manage their own mouse events.
+func (a *App) HandlePanelClick(viewID string) {
+ _ = a.handlePanelClick(viewID) // error intentionally ignored: click handler fallback
+}
+
// handlePanelClick handles mouse click on a panel to switch focus
func (a *App) handlePanelClick(viewID string) error {
// Ignore if modal is active
@@ -269,13 +305,11 @@ func (a *App) RegisterMouseBindings() {
}
}
- // Register special bindings for MigrationsPanel
- if migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel); ok {
- migrationsPanel.SetApp(a)
-
+ // Register special bindings for MigrationsContext
+ if migrationsCtx, ok := a.panels[ViewMigrations].(*context.MigrationsContext); ok {
// Tab click binding
a.g.SetTabClickBinding(ViewMigrations, func(tabIndex int) error {
- return migrationsPanel.handleTabClick(tabIndex)
+ return migrationsCtx.HandleTabClick(tabIndex)
})
// List item click binding
@@ -284,16 +318,16 @@ func (a *App) RegisterMouseBindings() {
Key: gocui.MouseLeft,
Modifier: gocui.ModNone,
Handler: func(opts gocui.ViewMouseBindingOpts) error {
- return migrationsPanel.handleListClick(opts.Y)
+ return migrationsCtx.HandleListClick(opts.Y)
},
})
}
- // Register special bindings for DetailsPanel
- if detailsPanel, ok := a.panels[ViewDetails].(*DetailsPanel); ok {
+ // Register special bindings for DetailsContext
+ if detailsCtx, ok := a.panels[ViewDetails].(*context.DetailsContext); ok {
// Tab click binding
a.g.SetTabClickBinding(ViewDetails, func(tabIndex int) error {
- return detailsPanel.handleTabClick(tabIndex)
+ return detailsCtx.HandleTabClick(tabIndex)
})
// Panel focus click binding (for content area)
@@ -307,7 +341,7 @@ func (a *App) RegisterMouseBindings() {
// registerMouseWheelBindings registers mouse wheel handlers for all panels
func (a *App) registerMouseWheelBindings() {
// Workspace panel
- if workspacePanel, ok := a.panels[ViewWorkspace].(*WorkspacePanel); ok {
+ if workspaceCtx, ok := a.panels[ViewWorkspace].(*context.WorkspaceContext); ok {
a.g.SetViewClickBinding(&gocui.ViewMouseBinding{
ViewName: ViewWorkspace,
Key: gocui.MouseWheelUp,
@@ -316,7 +350,7 @@ func (a *App) registerMouseWheelBindings() {
if a.HasActiveModal() {
return nil
}
- workspacePanel.ScrollUpByWheel()
+ workspaceCtx.ScrollUpByWheel()
return nil
},
})
@@ -328,14 +362,14 @@ func (a *App) registerMouseWheelBindings() {
if a.HasActiveModal() {
return nil
}
- workspacePanel.ScrollDownByWheel()
+ workspaceCtx.ScrollDownByWheel()
return nil
},
})
}
- // Migrations panel
- if migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel); ok {
+ // Migrations context
+ if migrationsCtx, ok := a.panels[ViewMigrations].(*context.MigrationsContext); ok {
a.g.SetViewClickBinding(&gocui.ViewMouseBinding{
ViewName: ViewMigrations,
Key: gocui.MouseWheelUp,
@@ -344,7 +378,7 @@ func (a *App) registerMouseWheelBindings() {
if a.HasActiveModal() {
return nil
}
- migrationsPanel.ScrollUpByWheel()
+ migrationsCtx.ScrollUpByWheel()
return nil
},
})
@@ -356,14 +390,14 @@ func (a *App) registerMouseWheelBindings() {
if a.HasActiveModal() {
return nil
}
- migrationsPanel.ScrollDownByWheel()
+ migrationsCtx.ScrollDownByWheel()
return nil
},
})
}
- // Details panel
- if detailsPanel, ok := a.panels[ViewDetails].(*DetailsPanel); ok {
+ // Details context
+ if detailsCtx, ok := a.panels[ViewDetails].(*context.DetailsContext); ok {
a.g.SetViewClickBinding(&gocui.ViewMouseBinding{
ViewName: ViewDetails,
Key: gocui.MouseWheelUp,
@@ -372,7 +406,7 @@ func (a *App) registerMouseWheelBindings() {
if a.HasActiveModal() {
return nil
}
- detailsPanel.ScrollUpByWheel()
+ detailsCtx.ScrollUpByWheel()
return nil
},
})
@@ -384,14 +418,14 @@ func (a *App) registerMouseWheelBindings() {
if a.HasActiveModal() {
return nil
}
- detailsPanel.ScrollDownByWheel()
+ detailsCtx.ScrollDownByWheel()
return nil
},
})
}
// Output panel
- if outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
+ if outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
a.g.SetViewClickBinding(&gocui.ViewMouseBinding{
ViewName: ViewOutputs,
Key: gocui.MouseWheelUp,
@@ -436,8 +470,8 @@ func (a *App) RefreshAll(onComplete ...func()) bool {
// Update UI on main thread (thread-safe)
a.g.Update(func(g *gocui.Gui) error {
// Add refresh notification to output panel
- if outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- outputPanel.LogAction("Refresh", "All panels have been refreshed")
+ if outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ outputPanel.LogAction(a.Tr.ActionRefresh, a.Tr.SuccessAllPanelsRefreshed)
}
// Execute callbacks
@@ -454,17 +488,25 @@ func (a *App) RefreshAll(onComplete ...func()) bool {
// refreshPanels refreshes all panels (blocking, internal)
func (a *App) refreshPanels() {
// Refresh workspace panel
- if workspacePanel, ok := a.panels[ViewWorkspace].(*WorkspacePanel); ok {
- workspacePanel.Refresh()
+ if workspaceCtx, ok := a.panels[ViewWorkspace].(*context.WorkspaceContext); ok {
+ workspaceCtx.Refresh()
}
- // Refresh migrations panel
- if migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel); ok {
- migrationsPanel.Refresh()
- }
-
- // Refresh Details panel Action-Needed data
- if detailsPanel, ok := a.panels[ViewDetails].(*DetailsPanel); ok {
- detailsPanel.LoadActionNeededData()
+ // Refresh migrations context
+ if migrationsCtx, ok := a.panels[ViewMigrations].(*context.MigrationsContext); ok {
+ migrationsCtx.Refresh()
+
+ // Wire action-needed data from migrations to details
+ if detailsCtx, ok := a.panels[ViewDetails].(*context.DetailsContext); ok {
+ // Collect action-needed migrations from Local category
+ var actionNeeded []prisma.Migration
+ for _, mig := range migrationsCtx.GetCategory().Local {
+ if mig.IsEmpty || mig.ChecksumMismatch {
+ actionNeeded = append(actionNeeded, mig)
+ }
+ }
+ detailsCtx.SetActionNeededMigrations(actionNeeded)
+ detailsCtx.LoadActionNeededData()
+ }
}
}
diff --git a/pkg/app/base_modal.go b/pkg/app/base_modal.go
new file mode 100644
index 0000000..4c537a8
--- /dev/null
+++ b/pkg/app/base_modal.go
@@ -0,0 +1,181 @@
+package app
+
+import (
+ "strings"
+
+ "github.com/dokadev/lazyprisma/pkg/i18n"
+ "github.com/jesseduffield/gocui"
+)
+
+// BaseModal provides common infrastructure shared by all modal types
+type BaseModal struct {
+ id string
+ g *gocui.Gui
+ tr *i18n.TranslationSet
+ style MessageModalStyle
+}
+
+// NewBaseModal creates a new BaseModal with default style
+func NewBaseModal(id string, g *gocui.Gui, tr *i18n.TranslationSet) *BaseModal {
+ return &BaseModal{
+ id: id,
+ g: g,
+ tr: tr,
+ style: MessageModalStyle{},
+ }
+}
+
+// SetStyle sets the modal style (used by embedding structs' WithStyle methods)
+func (b *BaseModal) SetStyle(style MessageModalStyle) {
+ b.style = style
+}
+
+// Style returns the current modal style
+func (b *BaseModal) Style() MessageModalStyle {
+ return b.style
+}
+
+// ID returns the modal's view ID
+func (b *BaseModal) ID() string {
+ return b.id
+}
+
+// CalculateDimensions computes centered coordinates for a modal view.
+// widthRatio is the fraction of screen width (e.g., 4.0/7.0).
+// heightContent is the number of content lines (borders added by caller).
+// minWidth is the minimum width for the modal.
+func (b *BaseModal) CalculateDimensions(widthRatio float64, minWidth int) (width int) {
+ screenWidth, _ := b.g.Size()
+
+ width = int(widthRatio * float64(screenWidth))
+ if width < minWidth {
+ if screenWidth-2 < minWidth {
+ width = screenWidth - 2
+ } else {
+ width = minWidth
+ }
+ }
+
+ return width
+}
+
+// CenterBox returns centered screen coordinates for a box of the given width and height.
+func (b *BaseModal) CenterBox(width, height int) (x0, y0, x1, y1 int) {
+ screenWidth, screenHeight := b.g.Size()
+
+ // Clamp height to screen
+ maxHeight := screenHeight - 4
+ if height > maxHeight {
+ height = maxHeight
+ }
+
+ x0 = (screenWidth - width) / 2
+ y0 = (screenHeight - height) / 2
+ x1 = x0 + width
+ y1 = y0 + height
+ return
+}
+
+// SetupView creates (or retrieves) a gocui view and applies common frame settings.
+// It handles the "unknown view" error from SetView, applies frame runes, title, footer,
+// and style colours. Returns the view and whether it was newly created.
+func (b *BaseModal) SetupView(name string, x0, y0, x1, y1 int, zIndex byte, title, footer string) (*gocui.View, bool, error) {
+ v, err := b.g.SetView(name, x0, y0, x1, y1, zIndex)
+ isNew := err != nil
+ if err != nil && err.Error() != "unknown view" {
+ return nil, false, err
+ }
+
+ v.Frame = true
+ v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
+ v.Title = title
+ v.Footer = footer
+
+ // Apply frame color (border) if set
+ if b.style.BorderColor != ColorDefault {
+ v.FrameColor = gocui.Attribute(ColorToGocuiAttr(b.style.BorderColor))
+ }
+
+ // Apply title color if set
+ if b.style.TitleColor != ColorDefault {
+ v.TitleColor = gocui.Attribute(ColorToGocuiAttr(b.style.TitleColor))
+ }
+
+ return v, isNew, nil
+}
+
+// OnClose deletes the modal's primary view
+func (b *BaseModal) OnClose() {
+ b.g.DeleteView(b.id)
+}
+
+// ColorToGocuiAttr converts a Color to a gocui color attribute value.
+// Exported so it can be used by any code that needs this conversion.
+func ColorToGocuiAttr(c Color) int {
+ switch c {
+ case ColorBlack:
+ return int(gocui.ColorBlack)
+ case ColorRed:
+ return int(gocui.ColorRed)
+ case ColorGreen:
+ return int(gocui.ColorGreen)
+ case ColorYellow:
+ return int(gocui.ColorYellow)
+ case ColorBlue:
+ return int(gocui.ColorBlue)
+ case ColorMagenta:
+ return int(gocui.ColorMagenta)
+ case ColorCyan:
+ return int(gocui.ColorCyan)
+ case ColorWhite:
+ return int(gocui.ColorWhite)
+ default:
+ return int(gocui.ColorDefault)
+ }
+}
+
+// WrapText wraps text to fit within the specified width.
+// Each resulting line is prefixed with the given padding string.
+// Handles multiple paragraphs separated by newlines.
+func WrapText(text string, maxWidth int, padding string) []string {
+ if maxWidth <= 0 {
+ return []string{padding + text}
+ }
+
+ var lines []string
+ paragraphs := strings.Split(text, "\n")
+
+ for _, para := range paragraphs {
+ if len(para) == 0 {
+ lines = append(lines, "")
+ continue
+ }
+
+ if len(para) <= maxWidth {
+ lines = append(lines, padding+para)
+ } else {
+ // Word wrapping
+ words := strings.Fields(para)
+ currentLine := padding
+
+ for _, word := range words {
+ if len(currentLine)+len(word)+1 <= maxWidth+len(padding) {
+ if currentLine == padding {
+ currentLine += word
+ } else {
+ currentLine += " " + word
+ }
+ } else {
+ lines = append(lines, currentLine)
+ currentLine = padding + word
+ }
+ }
+
+ if currentLine != padding {
+ lines = append(lines, currentLine)
+ }
+ }
+ }
+
+ return lines
+}
diff --git a/pkg/app/clipboard_controller.go b/pkg/app/clipboard_controller.go
new file mode 100644
index 0000000..79a8955
--- /dev/null
+++ b/pkg/app/clipboard_controller.go
@@ -0,0 +1,82 @@
+package app
+
+import (
+ "fmt"
+
+ "github.com/dokadev/lazyprisma/pkg/gui/context"
+)
+
+// CopyMigrationInfo copies migration info to clipboard
+func (a *App) CopyMigrationInfo() {
+ // Get migrations panel
+ migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext)
+ if !ok {
+ return
+ }
+
+ // Get selected migration
+ selected := migrationsPanel.GetSelectedMigration()
+ if selected == nil {
+ return
+ }
+
+ items := []ListModalItem{
+ {
+ Label: a.Tr.ListItemCopyName,
+ Description: selected.Name,
+ OnSelect: func() error {
+ a.CloseModal()
+ a.copyTextToClipboard(selected.Name, a.Tr.CopyLabelMigrationName)
+ return nil
+ },
+ },
+ {
+ Label: a.Tr.ListItemCopyPath,
+ Description: selected.Path,
+ OnSelect: func() error {
+ a.CloseModal()
+ a.copyTextToClipboard(selected.Path, a.Tr.CopyLabelMigrationPath)
+ return nil
+ },
+ },
+ }
+
+ // If it has a checksum, allow copying it
+ if selected.Checksum != "" {
+ items = append(items, ListModalItem{
+ Label: a.Tr.ListItemCopyChecksum,
+ Description: selected.Checksum,
+ OnSelect: func() error {
+ a.CloseModal()
+ a.copyTextToClipboard(selected.Checksum, a.Tr.CopyLabelChecksum)
+ return nil
+ },
+ })
+ }
+
+ modal := NewListModal(a.g, a.Tr, a.Tr.ModalTitleCopyToClipboard, items,
+ func() {
+ a.CloseModal()
+ },
+ ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan})
+
+ a.OpenModal(modal)
+}
+
+func (a *App) copyTextToClipboard(text, label string) {
+ if err := CopyToClipboard(text); err != nil {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleClipboardError,
+ a.Tr.ModalMsgFailedCopyClipboard,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Show toast/notification via modal for now
+ // Ideally we would have a toast system
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCopied,
+ fmt.Sprintf(a.Tr.ModalMsgCopiedToClipboard, label),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
+ a.OpenModal(modal)
+}
diff --git a/pkg/app/command.go b/pkg/app/command.go
deleted file mode 100644
index 1b3e911..0000000
--- a/pkg/app/command.go
+++ /dev/null
@@ -1,1138 +0,0 @@
-package app
-
-import (
- "fmt"
- "os"
- "strings"
- "time"
-
- "github.com/dokadev/lazyprisma/pkg/commands"
- "github.com/dokadev/lazyprisma/pkg/prisma"
- "github.com/jesseduffield/gocui"
-)
-
-// MigrateDeploy runs npx prisma migrate deploy
-func (a *App) MigrateDeploy() {
- // Try to start command - if another command is running, block
- if !a.tryStartCommand("Migrate Deploy") {
- a.logCommandBlocked("Migrate Deploy")
- return
- }
-
- // Run everything in background to avoid blocking UI during refresh/checks
- go func() {
- // 1. Refresh first to ensure DB connection is current
- a.refreshPanels()
-
- // 2. Check DB connection
- migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel)
- if !ok {
- a.finishCommand()
- a.g.Update(func(g *gocui.Gui) error {
- modal := NewMessageModal(a.g, "Error",
- "Failed to access migrations panel.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return nil
- })
- return
- }
-
- // Check if DB is connected
- if !migrationsPanel.dbConnected {
- a.finishCommand()
- a.g.Update(func(g *gocui.Gui) error {
- modal := NewMessageModal(a.g, "Database Connection Required",
- "No database connection detected.",
- "Please ensure your database is running and accessible.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return nil
- })
- return
- }
-
- outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel)
- if !ok {
- a.finishCommand() // Clean up if panel not found
- return
- }
-
- // Get current working directory
- cwd, err := os.Getwd()
- if err != nil {
- a.finishCommand()
- a.g.Update(func(g *gocui.Gui) error {
- outputPanel.LogAction("Migrate Deploy Error", "Failed to get working directory: "+err.Error())
- modal := NewMessageModal(a.g, "Migrate Deploy Error",
- "Failed to get working directory:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return nil
- })
- return
- }
-
- // Log action start
- a.g.Update(func(g *gocui.Gui) error {
- outputPanel.LogAction("Migrate Deploy", "Running prisma migrate deploy...")
- return nil
- })
-
- // Create command builder
- builder := commands.NewCommandBuilder(commands.NewPlatform())
-
- // Build prisma migrate deploy command
- deployCmd := builder.New("npx", "prisma", "migrate", "deploy").
- WithWorkingDir(cwd).
- StreamOutput().
- OnStdout(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" " + line)
- }
- return nil
- })
- }).
- OnStderr(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" " + line)
- }
- return nil
- })
- }).
- OnComplete(func(exitCode int) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- a.finishCommand() // Finish command
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- if exitCode == 0 {
- out.LogAction("Migrate Deploy Complete", "Migrations applied successfully")
- // Refresh all panels to show updated migration status
- a.RefreshAll()
- // Show success modal
- modal := NewMessageModal(a.g, "Migrate Deploy Successful",
- "Migrations applied successfully!",
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(modal)
- } else {
- out.LogAction("Migrate Deploy Failed", fmt.Sprintf("Migrate deploy failed with exit code: %d", exitCode))
- // Refresh even on failure - DB state may have changed
- a.RefreshAll()
- modal := NewMessageModal(a.g, "Migrate Deploy Failed",
- fmt.Sprintf("Prisma migrate deploy failed with exit code: %d", exitCode),
- "Check output panel for details.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
- }
- return nil
- })
- }).
- OnError(func(err error) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- a.finishCommand() // Finish command
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.LogAction("Migrate Deploy Error", err.Error())
- modal := NewMessageModal(a.g, "Migrate Deploy Error",
- "Failed to run prisma migrate deploy:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
- return nil
- })
- })
-
- // Run async to avoid blocking UI (spinner will show automatically)
- if err := deployCmd.RunAsync(); err != nil {
- a.finishCommand() // Clean up if command fails to start
- a.g.Update(func(g *gocui.Gui) error {
- outputPanel.LogAction("Migrate Deploy Error", "Failed to start migrate deploy: "+err.Error())
- modal := NewMessageModal(a.g, "Migrate Deploy Error",
- "Failed to start migrate deploy:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return nil
- })
- }
- }()
-}
-
-// MigrateDev opens a list modal to choose migration type
-func (a *App) MigrateDev() {
- items := []ListModalItem{
- {
- Label: "Schema diff-based migration",
- Description: "Create a migration from changes in Prisma schema, apply it to the database, trigger generators (e.g. Prisma Client)",
- OnSelect: func() error {
- a.CloseModal()
- a.SchemaDiffMigration()
- return nil
- },
- },
- {
- Label: "Manual migration",
- Description: "This tool creates manual migrations for database changes that cannot be expressed through Prisma schema diff. It is used to explicitly record and version control database-specific logic such as triggers, functions, and DML operations that cannot be managed at the Prisma schema level.",
- OnSelect: func() error {
- a.CloseModal()
- a.showManualMigrationInput()
- return nil
- },
- },
- }
-
- modal := NewListModal(a.g, "Migrate Dev", items,
- func() {
- // Cancel - just close modal
- a.CloseModal()
- },
- ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan})
-
- a.OpenModal(modal)
-}
-
-// executeCreateMigration runs npx prisma migrate dev --name --create-only
-func (a *App) executeCreateMigration(migrationName string) {
- // Try to start command - if another command is running, block
- if !a.tryStartCommand("Create Migration") {
- a.logCommandBlocked("Create Migration")
- return
- }
-
- outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel)
- if !ok {
- a.finishCommand() // Clean up if panel not found
- return
- }
-
- // Get current working directory
- cwd, err := os.Getwd()
- if err != nil {
- a.finishCommand()
- outputPanel.LogAction("Migration Error", "Failed to get working directory: "+err.Error())
- modal := NewMessageModal(a.g, "Migration Error",
- "Failed to get working directory:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Log action start
- outputPanel.LogAction("Migrate Dev", fmt.Sprintf("Creating migration: %s", migrationName))
-
- // Create command builder
- builder := commands.NewCommandBuilder(commands.NewPlatform())
-
- // Build prisma migrate dev --create-only command
- // Note: --create-only flag creates the migration without applying it to the database
- createCmd := builder.New("npx", "prisma", "migrate", "dev", "--name", migrationName, "--create-only").
- WithWorkingDir(cwd).
- StreamOutput().
- OnStdout(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" " + line)
- }
- return nil
- })
- }).
- OnStderr(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" " + line)
- }
- return nil
- })
- }).
- OnComplete(func(exitCode int) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- a.finishCommand() // Finish command
- // Refresh all panels to show the new migration
- a.RefreshAll()
-
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- if exitCode == 0 {
- out.LogAction("Migrate Complete", "Migration created successfully")
- // Show success modal
- modal := NewMessageModal(a.g, "Migration Created",
- fmt.Sprintf("Migration '%s' created successfully!", migrationName),
- "You can find it in the prisma/migrations directory.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(modal)
- } else {
- out.LogAction("Migrate Failed", fmt.Sprintf("Migration creation failed with exit code: %d", exitCode))
- modal := NewMessageModal(a.g, "Migration Failed",
- fmt.Sprintf("Prisma migrate dev failed with exit code: %d", exitCode),
- "Check output panel for details.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
- }
- return nil
- })
- }).
- OnError(func(err error) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- a.finishCommand() // Finish command
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.LogAction("Migration Error", err.Error())
- modal := NewMessageModal(a.g, "Migration Error",
- "Failed to run prisma migrate dev:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
- return nil
- })
- })
-
- // Run async to avoid blocking UI (spinner will show automatically)
- if err := createCmd.RunAsync(); err != nil {
- a.finishCommand() // Clean up if command fails to start
- outputPanel.LogAction("Migration Error", "Failed to start migrate dev: "+err.Error())
- modal := NewMessageModal(a.g, "Migration Error",
- "Failed to start migrate dev:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
-}
-
-// SchemaDiffMigration performs schema diff-based migration with validation checks
-func (a *App) SchemaDiffMigration() {
- // 1. Refresh first (with callback to ensure data is loaded before checking)
- started := a.RefreshAll(func() {
- // 2. Check DB connection
- migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel)
- if !ok {
- modal := NewMessageModal(a.g, "Error",
- "Failed to access migrations panel.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Check if DB is connected
- if !migrationsPanel.dbConnected {
- modal := NewMessageModal(a.g, "Database Connection Required",
- "No database connection detected.",
- "Please ensure your database is running and accessible.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // 3. Check for DB-Only migrations
- if len(migrationsPanel.category.DBOnly) > 0 {
- modal := NewMessageModal(a.g, "DB-Only Migrations Detected",
- "Cannot create new migration whilst DB-Only migrations exist.",
- "Please resolve DB-Only migrations first.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // 4. Check for Checksum Mismatch
- for _, m := range migrationsPanel.category.Local {
- if m.ChecksumMismatch {
- modal := NewMessageModal(a.g, "Checksum Mismatch Detected",
- "Cannot create new migration whilst checksum mismatch exists.",
- fmt.Sprintf("Migration '%s' has been modified locally.", m.Name),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
- }
-
- // 5. Check for Pending migrations
- if len(migrationsPanel.category.Pending) > 0 {
- // Check if any pending migration is empty
- for _, m := range migrationsPanel.category.Pending {
- if m.IsEmpty {
- modal := NewMessageModal(a.g, "Empty Pending Migration Detected",
- "Cannot create new migration whilst empty pending migrations exist.",
- fmt.Sprintf("Migration '%s' is pending and empty.", m.Name),
- "Please delete it or add SQL content.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
- }
-
- // Show confirmation modal for normal pending migrations
- modal := NewConfirmModal(a.g, "Pending Migrations Detected",
- "Prisma automatically applies pending migrations before creating new ones. This may cause unintended behaviour in the future. Do you wish to continue?",
- func() {
- // Yes - proceed with migration name input
- a.CloseModal()
- a.showMigrationNameInput()
- },
- func() {
- // No - cancel
- a.CloseModal()
- },
- ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow})
- a.OpenModal(modal)
- return
- }
-
- // All checks passed - show migration name input
- a.showMigrationNameInput()
- })
-
- if !started {
- // If refresh failed to start (e.g., another command running), show error
- modal := NewMessageModal(a.g, "Operation Blocked",
- "Another operation is currently running.",
- "Please wait for it to complete.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
-}
-
-// createManualMigration creates a manual migration folder and file
-func (a *App) createManualMigration(migrationName string) {
- // Get current working directory
- cwd, err := os.Getwd()
- if err != nil {
- modal := NewMessageModal(a.g, "Error",
- "Failed to get working directory:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Generate timestamp (YYYYMMDDHHmmss format) in UTC to match Prisma CLI behavior
- timestamp := time.Now().UTC().Format("20060102150405")
- folderName := fmt.Sprintf("%s_%s", timestamp, migrationName)
-
- // Migration folder path (prisma/migrations/{timestamp}_{name})
- migrationsDir := fmt.Sprintf("%s/prisma/migrations", cwd)
- migrationFolder := fmt.Sprintf("%s/%s", migrationsDir, folderName)
-
- // Create migration folder
- if err := os.MkdirAll(migrationFolder, 0755); err != nil {
- modal := NewMessageModal(a.g, "Error",
- "Failed to create migration folder:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Create migration.sql file with initial comment
- migrationFile := fmt.Sprintf("%s/migration.sql", migrationFolder)
- initialContent := "-- This migration was manually created via lazyprisma\n\n"
-
- if err := os.WriteFile(migrationFile, []byte(initialContent), 0644); err != nil {
- modal := NewMessageModal(a.g, "Error",
- "Failed to create migration.sql:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Success - show result and refresh
- a.RefreshAll()
-
- modal := NewMessageModal(a.g, "Manual Migration Created",
- fmt.Sprintf("Created: %s", folderName),
- fmt.Sprintf("Location: %s", migrationFolder),
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(modal)
-}
-
-// showMigrationNameInput shows input modal for migration name
-func (a *App) showMigrationNameInput() {
- modal := NewInputModal(a.g, "Enter migration name",
- func(input string) {
- // Replace spaces with underscores
- migrationName := strings.ReplaceAll(strings.TrimSpace(input), " ", "_")
-
- // Close input modal
- a.CloseModal()
-
- // Execute actual migration creation
- a.executeCreateMigration(migrationName)
- },
- func() {
- // Cancel - just close modal
- a.CloseModal()
- },
- ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}).
- WithSubtitle("Spaces will be replaced with underscores").
- WithRequired(true).
- OnValidationFail(func(reason string) {
- // Validation failed - show error
- a.CloseModal()
- errorModal := NewMessageModal(a.g, "Validation Failed",
- reason,
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(errorModal)
- })
-
- a.OpenModal(modal)
-}
-
-// showManualMigrationInput shows input modal for manual migration name
-func (a *App) showManualMigrationInput() {
- modal := NewInputModal(a.g, "Enter migration name",
- func(input string) {
- // Replace spaces with underscores
- migrationName := strings.ReplaceAll(strings.TrimSpace(input), " ", "_")
-
- // Close input modal
- a.CloseModal()
-
- // Create manual migration
- a.createManualMigration(migrationName)
- },
- func() {
- // Cancel - just close modal
- a.CloseModal()
- },
- ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}).
- WithSubtitle("Spaces will be replaced with underscores").
- WithRequired(true).
- OnValidationFail(func(reason string) {
- // Validation failed - show error
- a.CloseModal()
- errorModal := NewMessageModal(a.g, "Validation Failed",
- reason,
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(errorModal)
- })
-
- a.OpenModal(modal)
-}
-
-// Generate runs prisma generate and shows result in modal
-func (a *App) Generate() {
- // Try to start command - if another command is running, block
- if !a.tryStartCommand("Generate") {
- a.logCommandBlocked("Generate")
- return
- }
-
- outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel)
- if !ok {
- a.finishCommand() // Clean up if panel not found
- return
- }
-
- // Get current working directory
- cwd, err := os.Getwd()
- if err != nil {
- a.finishCommand()
- outputPanel.LogAction("Generate Error", "Failed to get working directory: "+err.Error())
- modal := NewMessageModal(a.g, "Generate Error",
- "Failed to get working directory:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Log action start
- outputPanel.LogAction("Generate", "Running prisma generate...")
-
- // Create command builder
- builder := commands.NewCommandBuilder(commands.NewPlatform())
-
- // Build prisma generate command
- generateCmd := builder.New("npx", "prisma", "generate").
- WithWorkingDir(cwd).
- StreamOutput().
- OnStdout(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" " + line)
- }
- return nil
- })
- }).
- OnStderr(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" " + line)
- }
- return nil
- })
- }).
- OnComplete(func(exitCode int) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- if exitCode == 0 {
- a.finishCommand() // Finish immediately on success
- out.LogAction("Generate Complete", "Prisma Client generated successfully")
- // Show success modal
- modal := NewMessageModal(a.g, "Generate Successful",
- "Prisma Client generated successfully!",
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(modal)
- } else {
- // Failed - run validate to check schema (keep spinner running)
- out.LogAction("Generate Failed", "Checking schema for errors...")
-
- // Run validate in goroutine to not block UI updates
- go func() {
- validateResult, err := prisma.Validate(cwd)
-
- // Update UI on main thread after validate completes
- a.g.Update(func(g *gocui.Gui) error {
- a.finishCommand() // Finish after validate completes
-
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- if err == nil && !validateResult.Valid {
- // Schema has validation errors - show them
- out.LogAction("Schema Validation Failed", fmt.Sprintf("Found %d schema errors", len(validateResult.Errors)))
-
- // Show validation errors in modal
- modal := NewMessageModal(a.g, "Schema Validation Failed",
- "Generate failed due to schema errors.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- } else {
- // Schema is valid but generate failed for other reasons
- out.LogAction("Generate Failed", fmt.Sprintf("Generate failed with exit code: %d", exitCode))
- modal := NewMessageModal(a.g, "Generate Failed",
- fmt.Sprintf("Prisma generate failed with exit code: %d", exitCode),
- "Schema is valid. Check output panel for details.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
- }
- return nil
- })
- }()
- }
- }
- return nil
- })
- }).
- OnError(func(err error) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- // Check if it's an exit status error (command ran but failed)
- if strings.Contains(err.Error(), "exit status") {
- // Failed - run validate to check schema (keep spinner running)
- out.LogAction("Generate Failed", "Checking schema for errors...")
-
- // Run validate in goroutine to not block UI updates
- go func() {
- validateResult, validateErr := prisma.Validate(cwd)
-
- // Update UI on main thread after validate completes
- a.g.Update(func(g *gocui.Gui) error {
- a.finishCommand() // Finish after validate completes
-
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- if validateErr == nil && !validateResult.Valid {
- // Schema has validation errors - show them
- out.LogAction("Schema Validation Failed", fmt.Sprintf("Found %d schema errors", len(validateResult.Errors)))
-
- // Show validation errors in modal
- modal := NewMessageModal(a.g, "Schema Validation Failed",
- "Generate failed due to schema errors.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- } else {
- // Schema is valid but generate failed for other reasons
- out.LogAction("Generate Failed", err.Error())
- modal := NewMessageModal(a.g, "Generate Failed",
- "Prisma generate failed:",
- "Schema is valid. Check output panel for details.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
- }
- return nil
- })
- }()
- } else {
- // Other error (command couldn't start, etc.)
- a.finishCommand() // Finish immediately on startup error
- out.LogAction("Generate Error", err.Error())
- modal := NewMessageModal(a.g, "Generate Error",
- "Failed to run prisma generate:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
- }
- return nil
- })
- })
-
- // Run async to avoid blocking UI (spinner will show automatically)
- if err := generateCmd.RunAsync(); err != nil {
- a.finishCommand() // Clean up if command fails to start
- outputPanel.LogAction("Generate Error", "Failed to start generate: "+err.Error())
- modal := NewMessageModal(a.g, "Generate Error",
- "Failed to start generate:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
-}
-
-// MigrateResolve resolves a failed migration
-func (a *App) MigrateResolve() {
- // Get migrations panel
- migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel)
- if !ok {
- modal := NewMessageModal(a.g, "Error",
- "Failed to access migrations panel.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Get selected migration
- selectedMigration := migrationsPanel.GetSelectedMigration()
- if selectedMigration == nil {
- modal := NewMessageModal(a.g, "No Migration Selected",
- "Please select a migration to resolve.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow})
- a.OpenModal(modal)
- return
- }
-
- // Check if migration is failed (only In-Transaction migrations can be resolved)
- if !selectedMigration.IsFailed {
- modal := NewMessageModal(a.g, "Cannot Resolve Migration",
- "Only migrations in 'In-Transaction' state can be resolved.",
- fmt.Sprintf("Migration '%s' is not in a failed state.", selectedMigration.Name),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Show ListModal with resolve options
- migrationName := selectedMigration.Name
-
- items := []ListModalItem{
- {
- Label: "Mark as applied",
- Description: "Mark this migration as successfully applied to the database. Use this if you have manually fixed the issue and the migration changes are now present in the database.",
- OnSelect: func() error {
- a.CloseModal()
- a.executeResolve(migrationName, "applied")
- return nil
- },
- },
- {
- Label: "Mark as rolled back",
- Description: "Mark this migration as rolled back (reverted from the database). Use this if you have manually reverted the changes and the migration is no longer applied to the database.",
- OnSelect: func() error {
- a.CloseModal()
- a.executeResolve(migrationName, "rolled-back")
- return nil
- },
- },
- }
-
- modal := NewListModal(a.g, "Resolve Migration: "+migrationName, items,
- func() { a.CloseModal() },
- ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan})
-
- a.OpenModal(modal)
-}
-
-// executeResolve runs npx prisma migrate resolve with the specified action
-func (a *App) executeResolve(migrationName string, action string) {
- // Try to start command - if another command is running, block
- if !a.tryStartCommand("Migrate Resolve") {
- a.logCommandBlocked("Migrate Resolve")
- return
- }
-
- outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel)
- if !ok {
- a.finishCommand() // Clean up if panel not found
- return
- }
-
- // Get current working directory
- cwd, err := os.Getwd()
- if err != nil {
- a.finishCommand()
- outputPanel.LogAction("Migrate Resolve Error", "Failed to get working directory: "+err.Error())
- modal := NewMessageModal(a.g, "Migrate Resolve Error",
- "Failed to get working directory:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Log action start
- actionLabel := "applied"
- if action == "rolled-back" {
- actionLabel = "rolled back"
- }
- outputPanel.LogAction("Migrate Resolve", fmt.Sprintf("Marking migration as %s: %s", actionLabel, migrationName))
-
- // Create command builder
- builder := commands.NewCommandBuilder(commands.NewPlatform())
-
- // Build prisma migrate resolve command
- resolveCmd := builder.New("npx", "prisma", "migrate", "resolve", "--"+action, migrationName).
- WithWorkingDir(cwd).
- StreamOutput().
- OnStdout(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" " + line)
- }
- return nil
- })
- }).
- OnStderr(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" " + line)
- }
- return nil
- })
- }).
- OnComplete(func(exitCode int) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- a.finishCommand() // Finish command
- // Refresh all panels to show updated migration status
- a.RefreshAll()
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- if exitCode == 0 {
- out.LogAction("Migrate Resolve Complete", fmt.Sprintf("Migration marked as %s successfully", actionLabel))
- // Show success modal
- modal := NewMessageModal(a.g, "Migrate Resolve Successful",
- fmt.Sprintf("Migration marked as %s successfully!", actionLabel),
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(modal)
- } else {
- out.LogAction("Migrate Resolve Failed", fmt.Sprintf("Migrate resolve failed with exit code: %d", exitCode))
- modal := NewMessageModal(a.g, "Migrate Resolve Failed",
- fmt.Sprintf("Prisma migrate resolve failed with exit code: %d", exitCode),
- "Check output panel for details.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
- }
- return nil
- })
- }).
- OnError(func(err error) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- a.finishCommand() // Finish command
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.LogAction("Migrate Resolve Error", err.Error())
- modal := NewMessageModal(a.g, "Migrate Resolve Error",
- "Failed to run prisma migrate resolve:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
- return nil
- })
- })
-
- // Run async to avoid blocking UI (spinner will show automatically)
- if err := resolveCmd.RunAsync(); err != nil {
- a.finishCommand() // Clean up if command fails to start
- outputPanel.LogAction("Migrate Resolve Error", "Failed to start migrate resolve: "+err.Error())
- modal := NewMessageModal(a.g, "Migrate Resolve Error",
- "Failed to start migrate resolve:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
-}
-
-// Studio toggles Prisma Studio
-func (a *App) Studio() {
- outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel)
- if !ok {
- return
- }
-
- // Check if Studio is already running
- if a.studioRunning {
- // Stop Studio
- if a.studioCmd != nil {
- if err := a.studioCmd.Kill(); err != nil {
- outputPanel.LogAction("Studio Error", "Failed to stop Prisma Studio: "+err.Error())
- modal := NewMessageModal(a.g, "Studio Error",
- "Failed to stop Prisma Studio:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
- a.studioCmd = nil
- }
- a.studioRunning = false
- outputPanel.LogAction("Studio Stopped", "Prisma Studio has been stopped")
-
- // Clear subtitle
- outputPanel.SetSubtitle("")
-
- // Update UI
- a.g.Update(func(g *gocui.Gui) error {
- // Trigger redraw of status bar
- return nil
- })
-
- modal := NewMessageModal(a.g, "Studio Stopped",
- "Prisma Studio has been stopped.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow})
- a.OpenModal(modal)
- return
- }
-
- // Start Studio
- // Try to start command - if another command is running, block
- if !a.tryStartCommand("Start Studio") {
- a.logCommandBlocked("Start Studio")
- return
- }
-
- // Get current working directory
- cwd, err := os.Getwd()
- if err != nil {
- a.finishCommand()
- outputPanel.LogAction("Studio Error", "Failed to get working directory: "+err.Error())
- modal := NewMessageModal(a.g, "Studio Error",
- "Failed to get working directory:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Log action start
- outputPanel.LogAction("Studio", "Starting Prisma Studio...")
-
- // Create command builder
- builder := commands.NewCommandBuilder(commands.NewPlatform())
-
- // Build prisma studio command
- // Note: We don't use StreamOutput here because Studio is a long-running process
- // and we want to capture the command object to kill it later
- studioCmd := builder.New("npx", "prisma", "studio").
- WithWorkingDir(cwd)
-
- // Start async
- if err := studioCmd.RunAsync(); err != nil {
- a.finishCommand()
- outputPanel.LogAction("Studio Error", "Failed to start Prisma Studio: "+err.Error())
- modal := NewMessageModal(a.g, "Studio Error",
- "Failed to start Prisma Studio:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Wait a bit to ensure it started, then finish the "starting" command
- // The process continues running in background
- go func() {
- time.Sleep(2 * time.Second)
- a.g.Update(func(g *gocui.Gui) error {
- a.finishCommand() // Finish "starting" command
- a.studioRunning = true
- a.studioCmd = studioCmd // Save Command object
-
- outputPanel.LogAction("Studio Started", "Prisma Studio is running at http://localhost:5555")
- outputPanel.SetSubtitle("Prisma Studio listening on http://localhost:5555")
-
- // Show info modal
- modal := NewMessageModal(a.g, "Prisma Studio Started",
- "Prisma Studio is running at http://localhost:5555",
- "Press 'S' again to stop it.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(modal)
- return nil
- })
- }()
-}
-
-// DeleteMigration deletes a pending migration
-func (a *App) DeleteMigration() {
- // Get migrations panel
- migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel)
- if !ok {
- return
- }
-
- // Get selected migration
- selected := migrationsPanel.GetSelectedMigration()
- if selected == nil {
- modal := NewMessageModal(a.g, "No Selection",
- "Please select a migration to delete.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow})
- a.OpenModal(modal)
- return
- }
-
- // Validate: Can only delete if it exists locally
- if selected.Path == "" {
- modal := NewMessageModal(a.g, "Cannot Delete",
- "This migration exists only in the database (DB-Only).",
- "Cannot delete a migration that has no local file.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Validate: Can only delete pending migrations (not applied to DB)
- // Exception: If DB is not connected, we assume it's safe to delete local files (user responsibility)
- if migrationsPanel.dbConnected && selected.AppliedAt != nil {
- modal := NewMessageModal(a.g, "Cannot Delete",
- "This migration has already been applied to the database.",
- "Deleting it locally will cause inconsistency.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Confirm deletion
- modal := NewConfirmModal(a.g, "Delete Migration",
- fmt.Sprintf("Are you sure you want to delete this migration?\n\n%s\n\nThis action cannot be undone.", selected.Name),
- func() {
- a.CloseModal()
- a.executeDeleteMigration(selected.Path, selected.Name)
- },
- func() {
- a.CloseModal()
- },
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
-}
-
-// executeDeleteMigration performs the actual deletion
-func (a *App) executeDeleteMigration(path, name string) {
- if err := os.RemoveAll(path); err != nil {
- outputPanel, _ := a.panels[ViewOutputs].(*OutputPanel)
- if outputPanel != nil {
- outputPanel.LogActionRed("Delete Error", "Failed to delete migration: "+err.Error())
- }
-
- modal := NewMessageModal(a.g, "Delete Error",
- "Failed to delete migration folder:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Success
- outputPanel, _ := a.panels[ViewOutputs].(*OutputPanel)
- if outputPanel != nil {
- outputPanel.LogAction("Deleted", fmt.Sprintf("Migration '%s' deleted", name))
- }
-
- // Refresh to update list
- a.RefreshAll()
-
- modal := NewMessageModal(a.g, "Deleted",
- "Migration deleted successfully.",
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(modal)
-}
-
-// CopyMigrationInfo copies migration info to clipboard
-func (a *App) CopyMigrationInfo() {
- // Get migrations panel
- migrationsPanel, ok := a.panels[ViewMigrations].(*MigrationsPanel)
- if !ok {
- return
- }
-
- // Get selected migration
- selected := migrationsPanel.GetSelectedMigration()
- if selected == nil {
- return
- }
-
- items := []ListModalItem{
- {
- Label: "Copy Name",
- Description: selected.Name,
- OnSelect: func() error {
- a.CloseModal()
- a.copyTextToClipboard(selected.Name, "Migration Name")
- return nil
- },
- },
- {
- Label: "Copy Path",
- Description: selected.Path,
- OnSelect: func() error {
- a.CloseModal()
- a.copyTextToClipboard(selected.Path, "Migration Path")
- return nil
- },
- },
- }
-
- // If it has a checksum, allow copying it
- if selected.Checksum != "" {
- items = append(items, ListModalItem{
- Label: "Copy Checksum",
- Description: selected.Checksum,
- OnSelect: func() error {
- a.CloseModal()
- a.copyTextToClipboard(selected.Checksum, "Checksum")
- return nil
- },
- })
- }
-
- modal := NewListModal(a.g, "Copy to Clipboard", items,
- func() {
- a.CloseModal()
- },
- ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan})
-
- a.OpenModal(modal)
-}
-
-func (a *App) copyTextToClipboard(text, label string) {
- if err := CopyToClipboard(text); err != nil {
- modal := NewMessageModal(a.g, "Clipboard Error",
- "Failed to copy to clipboard:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Show toast/notification via modal for now
- // Ideally we would have a toast system
- modal := NewMessageModal(a.g, "Copied",
- fmt.Sprintf("%s copied to clipboard!", label),
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(modal)
-}
diff --git a/pkg/app/confirm_modal.go b/pkg/app/confirm_modal.go
index 2c579cb..27e30de 100644
--- a/pkg/app/confirm_modal.go
+++ b/pkg/app/confirm_modal.go
@@ -2,105 +2,62 @@ package app
import (
"fmt"
- "strings"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazycore/pkg/boxlayout"
)
// ConfirmModal displays a confirmation dialog with Yes/No options
type ConfirmModal struct {
- g *gocui.Gui
+ *BaseModal
title string
message string
onYes func()
onNo func()
width int
height int
- style MessageModalStyle
}
// NewConfirmModal creates a new confirmation modal
-func NewConfirmModal(g *gocui.Gui, title string, message string, onYes func(), onNo func()) *ConfirmModal {
+func NewConfirmModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, message string, onYes func(), onNo func()) *ConfirmModal {
return &ConfirmModal{
- g: g,
- title: title,
- message: message,
- onYes: onYes,
- onNo: onNo,
- style: MessageModalStyle{}, // Default style
+ BaseModal: NewBaseModal("confirm_modal", g, tr),
+ title: title,
+ message: message,
+ onYes: onYes,
+ onNo: onNo,
}
}
// WithStyle sets the modal style
func (m *ConfirmModal) WithStyle(style MessageModalStyle) *ConfirmModal {
- m.style = style
+ m.SetStyle(style)
return m
}
-// ID returns the modal's view ID
-func (m *ConfirmModal) ID() string {
- return "confirm_modal"
-}
-
// Draw renders the modal
func (m *ConfirmModal) Draw(dim boxlayout.Dimensions) error {
- // Get screen size
- screenWidth, screenHeight := m.g.Size()
-
- // Calculate width (4/7 of screen, min 80)
- m.width = 4 * screenWidth / 7
- minWidth := 80
- if m.width < minWidth {
- if screenWidth-2 < minWidth {
- m.width = screenWidth - 2
- } else {
- m.width = minWidth
- }
- }
+ // Calculate width
+ m.width = m.CalculateDimensions(4.0/7.0, 80)
// Parse message into lines
availableWidth := m.width - 4
- lines := m.wrapText(m.message, availableWidth)
+ lines := WrapText(m.message, availableWidth, " ")
// Calculate height based on content
- contentHeight := len(lines)
- m.height = contentHeight + 2 // +2 for borders
-
- // Don't exceed screen height
- maxHeight := screenHeight - 4
- if m.height > maxHeight {
- m.height = maxHeight
- }
+ m.height = len(lines) + 2 // +2 for borders
// Center the modal
- x0 := (screenWidth - m.width) / 2
- y0 := (screenHeight - m.height) / 2
- x1 := x0 + m.width
- y1 := y0 + m.height
+ x0, y0, x1, y1 := m.CenterBox(m.width, m.height)
// Create modal view
- v, err := m.g.SetView(m.ID(), x0, y0, x1, y1, 0)
- if err != nil && err.Error() != "unknown view" {
+ v, _, err := m.SetupView(m.ID(), x0, y0, x1, y1, 0, " "+m.title+" ", m.tr.ModalFooterConfirmYesNo)
+ if err != nil {
return err
}
v.Clear()
- v.Frame = true
- v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
- v.Title = " " + m.title + " "
- v.Footer = " [Y] Yes [N] No [ESC] Cancel "
-
- // Apply frame color (border) if set
- if m.style.BorderColor != ColorDefault {
- v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor))
- }
-
- // Apply title color if set
- if m.style.TitleColor != ColorDefault {
- v.TitleColor = gocui.Attribute(colorToAnsiCode(m.style.TitleColor))
- }
-
v.Wrap = false
// Render content
@@ -111,50 +68,6 @@ func (m *ConfirmModal) Draw(dim boxlayout.Dimensions) error {
return nil
}
-// wrapText wraps text to fit within the specified width
-func (m *ConfirmModal) wrapText(text string, width int) []string {
- if width <= 0 {
- return []string{text}
- }
-
- var lines []string
-
- if len(text) == 0 {
- lines = append(lines, "")
- return lines
- }
-
- // Word wrap
- if len(text) <= width {
- lines = append(lines, " "+text)
- } else {
- // Simple word wrapping
- words := strings.Fields(text)
- currentLine := " "
-
- for _, word := range words {
- if len(currentLine)+len(word)+1 <= width+2 { // +2 for initial " "
- if currentLine == " " {
- currentLine += word
- } else {
- currentLine += " " + word
- }
- } else {
- // Current line is full, start new line
- lines = append(lines, currentLine)
- currentLine = " " + word
- }
- }
-
- // Add remaining line
- if currentLine != " " {
- lines = append(lines, currentLine)
- }
- }
-
- return lines
-}
-
// HandleKey handles keyboard input
func (m *ConfirmModal) HandleKey(key any, mod gocui.Modifier) error {
switch key {
@@ -177,6 +90,5 @@ func (m *ConfirmModal) HandleKey(key any, mod gocui.Modifier) error {
// OnClose is called when the modal is closed
func (m *ConfirmModal) OnClose() {
- // Delete the modal view
- m.g.DeleteView(m.ID())
+ m.BaseModal.OnClose()
}
diff --git a/pkg/app/details.go b/pkg/app/details.go
deleted file mode 100644
index 04747bc..0000000
--- a/pkg/app/details.go
+++ /dev/null
@@ -1,761 +0,0 @@
-package app
-
-import (
- "bytes"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/alecthomas/chroma/v2/formatters"
- "github.com/alecthomas/chroma/v2/lexers"
- "github.com/alecthomas/chroma/v2/styles"
- "github.com/dokadev/lazyprisma/pkg/prisma"
- "github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazycore/pkg/boxlayout"
-)
-
-type DetailsPanel struct {
- BasePanel
- content string
- originY int // Scroll position
- currentMigrationName string // Currently displayed migration name
- migrationsPanel *MigrationsPanel
-
- // Tab management
- tabs []string // Tab names (Details, Action-Needed)
- tabIndex int // Current tab index
- actionNeededMigrations []prisma.Migration // Migrations requiring action (Empty + Mismatch)
- validationResult *prisma.ValidateResult // Schema validation result
- tabOriginY map[string]int // Scroll position per tab
-
- // App reference for modal check (tab click events)
- app *App
-}
-
-func NewDetailsPanel(g *gocui.Gui) *DetailsPanel {
- return &DetailsPanel{
- BasePanel: NewBasePanel(ViewDetails, g),
- content: "Details\n\nSelect a migration to view details...",
- tabs: []string{"Details"}, // Start with Details tab only
- tabIndex: 0,
- actionNeededMigrations: []prisma.Migration{},
- tabOriginY: make(map[string]int),
- }
-}
-
-func (d *DetailsPanel) Draw(dim boxlayout.Dimensions) error {
- v, err := d.g.SetView(d.id, dim.X0, dim.Y0, dim.X1, dim.Y1, 0)
- if err != nil && err.Error() != "unknown view" {
- return err
- }
-
- // Setup view WITHOUT title (tabs replace title)
- d.v = v
- v.Clear()
- v.Frame = true
- v.FrameRunes = d.frameRunes
- v.Wrap = true // Enable word wrap for long lines
-
- // Set tabs
- v.Tabs = d.tabs
- v.TabIndex = d.tabIndex
-
- // Set frame and tab colors based on focus
- if d.focused {
- v.FrameColor = FocusedFrameColor
- v.TitleColor = FocusedTitleColor
- if len(d.tabs) == 1 {
- v.SelFgColor = FocusedTitleColor // Single tab: treat like title
- } else {
- v.SelFgColor = FocusedActiveTabColor // Multiple tabs: use active tab color
- }
- } else {
- v.FrameColor = PrimaryFrameColor
- v.TitleColor = PrimaryTitleColor
- if len(d.tabs) == 1 {
- v.SelFgColor = PrimaryTitleColor // Single tab: treat like title
- } else {
- v.SelFgColor = PrimaryActiveTabColor // Multiple tabs: use active tab color
- }
- }
-
- // Render content based on current tab
- if d.tabIndex < len(d.tabs) {
- tabName := d.tabs[d.tabIndex]
- if tabName == "Action-Needed" {
- fmt.Fprint(v, d.buildActionNeededContent())
- } else {
- fmt.Fprint(v, d.content)
- }
- }
-
- // Adjust origin to ensure it's within valid bounds
- AdjustOrigin(v, &d.originY)
- v.SetOrigin(0, d.originY)
-
- return nil
-}
-
-func (d *DetailsPanel) SetContent(content string) {
- d.content = content
-}
-
-// buildActionNeededContent builds the content for the Action-Needed tab
-func (d *DetailsPanel) buildActionNeededContent() string {
- // Count all issues
- emptyCount := 0
- mismatchCount := 0
- var emptyMigrations []prisma.Migration
- var mismatchMigrations []prisma.Migration
-
- for _, mig := range d.actionNeededMigrations {
- if mig.IsEmpty {
- emptyCount++
- emptyMigrations = append(emptyMigrations, mig)
- }
- if mig.ChecksumMismatch {
- mismatchCount++
- mismatchMigrations = append(mismatchMigrations, mig)
- }
- }
-
- validationErrorCount := 0
- if d.validationResult != nil && !d.validationResult.Valid {
- validationErrorCount = len(d.validationResult.Errors)
- if validationErrorCount == 0 {
- validationErrorCount = 1 // At least one error if validation failed
- }
- }
-
- totalCount := emptyCount + mismatchCount + validationErrorCount
-
- if totalCount == 0 {
- return "No action required\n\nAll migrations are in good state and schema is valid."
- }
-
- var content strings.Builder
-
- // Header
- content.WriteString(fmt.Sprintf("%s (%d issue", Yellow("⚠ Action Needed"), totalCount))
- if totalCount > 1 {
- content.WriteString("s")
- }
- content.WriteString(")\n\n")
-
- // Empty Migrations Section
- if emptyCount > 0 {
- content.WriteString(strings.Repeat("━", 40) + "\n")
- content.WriteString(fmt.Sprintf("%s (%d)\n", Red("Empty Migrations"), emptyCount))
- content.WriteString(strings.Repeat("━", 40) + "\n\n")
-
- content.WriteString("These migrations have no SQL content.\n\n")
-
- content.WriteString("Affected:\n")
- for _, mig := range emptyMigrations {
- _, name := parseMigrationName(mig.Name)
- content.WriteString(fmt.Sprintf(" • %s\n", Red(name)))
- }
-
- content.WriteString("\nRecommended Actions:\n")
- content.WriteString(" → Add migration.sql manually\n")
- content.WriteString(" → Delete empty migration folders\n")
- content.WriteString(" → Mark as baseline migration\n\n")
- }
-
- // Checksum Mismatch Section
- if mismatchCount > 0 {
- content.WriteString(strings.Repeat("━", 40) + "\n")
- content.WriteString(fmt.Sprintf("%s (%d)\n", Orange("Checksum Mismatch"), mismatchCount))
- content.WriteString(strings.Repeat("━", 40) + "\n\n")
-
- content.WriteString("Migration content was modified after\n")
- content.WriteString("being applied to database.\n\n")
-
- content.WriteString(Yellow("⚠ WARNING: "))
- content.WriteString("Editing applied migrations\n")
- content.WriteString("can cause inconsistencies.\n\n")
-
- content.WriteString("Affected:\n")
- for _, mig := range mismatchMigrations {
- _, name := parseMigrationName(mig.Name)
- content.WriteString(fmt.Sprintf(" • %s\n", Orange(name)))
- }
-
- content.WriteString("\nRecommended Actions:\n")
- content.WriteString(" → Revert local changes\n")
- content.WriteString(" → Create new migration instead\n")
- content.WriteString(" → Contact team if needed\n\n")
- }
-
- // Schema Validation Section
- if validationErrorCount > 0 {
- content.WriteString(strings.Repeat("━", 40) + "\n")
- content.WriteString(fmt.Sprintf("%s (%d)\n", Red("Schema Validation Errors"), validationErrorCount))
- content.WriteString(strings.Repeat("━", 40) + "\n\n")
-
- content.WriteString("Schema validation failed.\n")
- content.WriteString("Fix these issues before running migrations.\n\n")
-
- // Show full validation output (contains detailed error info)
- if d.validationResult.Output != "" {
- content.WriteString(Stylize("Validation Output:", Style{FgColor: ColorYellow, Bold: true}) + "\n")
- // Display the full output with proper formatting (preserve all line breaks)
- outputLines := strings.Split(d.validationResult.Output, "\n")
- for _, line := range outputLines {
- // Highlight error lines
- if strings.Contains(line, "Error:") || strings.Contains(line, "error:") {
- content.WriteString(Red(line) + "\n")
- } else if strings.Contains(line, "-->") {
- content.WriteString(Yellow(line) + "\n")
- } else {
- // Preserve empty lines and all other content as-is
- content.WriteString(line + "\n")
- }
- }
- content.WriteString("\n")
- }
-
- content.WriteString(Stylize("Recommended Actions:", Style{FgColor: ColorYellow, Bold: true}) + "\n")
- content.WriteString(" → Fix schema.prisma errors\n")
- content.WriteString(" → Check line numbers in output above\n")
- content.WriteString(" → Refer to Prisma documentation\n")
- }
-
- return content.String()
-}
-
-// highlightSQL applies syntax highlighting to SQL code with line numbers
-func highlightSQL(code string) string {
- // Get SQL lexer
- lexer := lexers.Get("sql")
- if lexer == nil {
- lexer = lexers.Fallback
- }
-
- // Get style (monokai is a popular dark theme)
- style := styles.Get("dracula")
- if style == nil {
- style = styles.Fallback
- }
-
- // Get terminal formatter with 256 colors
- formatter := formatters.Get("terminal256")
- if formatter == nil {
- formatter = formatters.Fallback
- }
-
- // Tokenize and format
- var buf bytes.Buffer
- iterator, err := lexer.Tokenise(nil, code)
- if err != nil {
- return code // Return original if highlighting fails
- }
-
- err = formatter.Format(&buf, style, iterator)
- if err != nil {
- return code // Return original if highlighting fails
- }
-
- // Add line numbers
- highlighted := buf.String()
- lines := strings.Split(highlighted, "\n")
- var result strings.Builder
-
- for i, line := range lines {
- if i > 0 {
- result.WriteString("\n")
- }
- // Line number in gray color, right-aligned to 4 digits
- result.WriteString(fmt.Sprintf("\033[90m%4d │\033[0m %s", i+1, line))
- }
-
- return result.String()
-}
-
-// ScrollUp scrolls the details panel up
-func (d *DetailsPanel) ScrollUp() {
- if d.originY > 0 {
- d.originY--
- }
-}
-
-// ScrollDown scrolls the details panel down
-func (d *DetailsPanel) ScrollDown() {
- d.originY++
- // AdjustOrigin will be called in Draw() to ensure bounds
-}
-
-// ScrollUpByWheel scrolls the details panel up by 2 lines (mouse wheel)
-func (d *DetailsPanel) ScrollUpByWheel() {
- if d.originY > 0 {
- d.originY -= 2
- if d.originY < 0 {
- d.originY = 0
- }
- }
-}
-
-// ScrollDownByWheel scrolls the details panel down by 2 lines (mouse wheel)
-func (d *DetailsPanel) ScrollDownByWheel() {
- if d.v == nil {
- return
- }
-
- // Get actual content lines from the rendered view buffer
- contentLines := len(d.v.ViewBufferLines())
- _, viewHeight := d.v.Size()
- innerHeight := viewHeight - 2 // Exclude frame (top + bottom)
-
- // Calculate maxOrigin
- maxOrigin := contentLines - innerHeight
- if maxOrigin < 0 {
- maxOrigin = 0
- }
-
- // Only scroll if we haven't reached the bottom
- if d.originY < maxOrigin {
- d.originY += 2
- if d.originY > maxOrigin {
- d.originY = maxOrigin
- }
- }
-}
-
-// ScrollToTop scrolls to the top of the details panel
-func (d *DetailsPanel) ScrollToTop() {
- d.originY = 0
-}
-
-// ScrollToBottom scrolls to the bottom of the details panel
-func (d *DetailsPanel) ScrollToBottom() {
- if d.v == nil {
- return
- }
-
- // Get actual content lines from the rendered view buffer
- contentLines := len(d.v.ViewBufferLines())
- _, viewHeight := d.v.Size()
- innerHeight := viewHeight - 2 // Exclude frame (top + bottom)
-
- // Calculate maxOrigin
- maxOrigin := contentLines - innerHeight
- if maxOrigin < 0 {
- maxOrigin = 0
- }
-
- d.originY = maxOrigin
-}
-
-// UpdateFromMigration updates the details panel with migration information
-func (d *DetailsPanel) UpdateFromMigration(migration *prisma.Migration, tabName string) {
- // Only reset scroll position for Details tab if viewing a different migration
- if migration != nil && d.currentMigrationName != migration.Name {
- // Reset Details tab scroll position only
- d.tabOriginY["Details"] = 0
- // If currently on Details tab, also update originY
- if d.tabIndex < len(d.tabs) && d.tabs[d.tabIndex] == "Details" {
- d.originY = 0
- }
- d.currentMigrationName = migration.Name
- } else if migration == nil {
- // Reset Details tab scroll position only
- d.tabOriginY["Details"] = 0
- // If currently on Details tab, also update originY
- if d.tabIndex < len(d.tabs) && d.tabs[d.tabIndex] == "Details" {
- d.originY = 0
- }
- d.currentMigrationName = ""
- }
-
- if migration == nil {
- d.content = "Details\n\nSelect a migration to view details..."
- return
- }
-
- // Handle different cases (priority: Failed > DB-Only > Checksum Mismatch > Empty)
-
- // In-Transaction migrations (highest priority)
- if migration.IsFailed {
- timestamp, name := parseMigrationName(migration.Name)
- header := fmt.Sprintf("Name: %s\n", Cyan(name))
- header += fmt.Sprintf("Timestamp: %s\n", timestamp)
- if migration.Path != "" {
- header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path))
- }
- header += fmt.Sprintf("Status: %s\n", Cyan("⚠ In-Transaction"))
-
- // Show down migration availability
- if migration.HasDownSQL {
- header += fmt.Sprintf("Down Migration: %s\n", Green("✓ Available"))
- } else {
- header += fmt.Sprintf("Down Migration: %s\n", Red("✗ Not available"))
- }
-
- // Show started_at if available
- if migration.StartedAt != nil {
- header += fmt.Sprintf("Started At: %s\n", migration.StartedAt.Format("2006-01-02 15:04:05"))
- }
-
- header += "\n" + Yellow("⚠ WARNING: This migration is stuck in an incomplete state.")
- header += "\n" + Yellow("No additional migrations can be applied until this is resolved.")
- header += "\n\nPlease resolve this migration manually before proceeding.\n"
-
- // Show logs if available
- if migration.Logs != nil && *migration.Logs != "" {
- header += "\nError Logs:\n" + Red(*migration.Logs)
- }
-
- // Read and show migration.sql content (if Path is available - not DB-Only)
- if migration.Path != "" {
- sqlPath := filepath.Join(migration.Path, "migration.sql")
- content, err := os.ReadFile(sqlPath)
- if err == nil {
- highlightedSQL := highlightSQL(string(content))
- d.content = header + "\n\n" + highlightedSQL
-
- // Show down.sql if available
- if migration.HasDownSQL {
- downSQLPath := filepath.Join(migration.Path, "down.sql")
- downContent, err := os.ReadFile(downSQLPath)
- if err == nil {
- highlightedDownSQL := highlightSQL(string(downContent))
- d.content += "\n\n" + Yellow("Down Migration SQL:") + "\n\n" + highlightedDownSQL
- }
- }
- } else {
- d.content = header
- }
- } else {
- d.content = header
- }
- return
- }
-
- if tabName == "DB-Only" {
- timestamp, name := parseMigrationName(migration.Name)
- header := fmt.Sprintf("Name: %s\n", Yellow(name))
- header += fmt.Sprintf("Timestamp: %s\n", timestamp)
- header += fmt.Sprintf("Status: %s\n\n", Red("✗ DB Only"))
- header += "This migration exists in the database but not in local files."
- d.content = header
- return
- }
-
- // Checksum mismatch
- if migration.ChecksumMismatch {
- timestamp, name := parseMigrationName(migration.Name)
- header := fmt.Sprintf("Name: %s\n", Orange(name))
- header += fmt.Sprintf("Timestamp: %s\n", timestamp)
- if migration.Path != "" {
- header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path))
- }
- // Show Applied status with Checksum Mismatch warning
- statusLine := fmt.Sprintf("Status: %s", Green("✓ Applied"))
- if migration.AppliedAt != nil {
- statusLine += fmt.Sprintf(" (Applied at: %s)", migration.AppliedAt.Format("2006-01-02 15:04:05"))
- }
- statusLine += fmt.Sprintf(" - %s\n", Orange("⚠ Checksum Mismatch"))
- header += statusLine
-
- // Show down migration availability
- if migration.HasDownSQL {
- header += fmt.Sprintf("Down Migration: %s\n", Green("✓ Available"))
- } else {
- header += fmt.Sprintf("Down Migration: %s\n", Red("✗ Not available"))
- }
-
- header += "\nThe local migration file has been modified after being applied to the database.\n"
- header += "This can cause issues during deployment.\n\n"
-
- // Show checksum values (in orange for emphasis)
- header += fmt.Sprintf("Local Checksum: %s\n", Orange(migration.Checksum))
- header += fmt.Sprintf("History Checksum: %s\n", Orange(migration.DBChecksum))
-
- // Read and show migration.sql content
- sqlPath := filepath.Join(migration.Path, "migration.sql")
- content, err := os.ReadFile(sqlPath)
- if err == nil {
- highlightedSQL := highlightSQL(string(content))
- d.content = header + "\n" + highlightedSQL
-
- // Show down.sql if available
- if migration.HasDownSQL {
- downSQLPath := filepath.Join(migration.Path, "down.sql")
- downContent, err := os.ReadFile(downSQLPath)
- if err == nil {
- highlightedDownSQL := highlightSQL(string(downContent))
- d.content += "\n\n" + Yellow("Down Migration SQL:") + "\n\n" + highlightedDownSQL
- }
- }
- } else {
- d.content = header
- }
- return
- }
-
- if migration.IsEmpty {
- timestamp, name := parseMigrationName(migration.Name)
- header := fmt.Sprintf("Name: %s\n", Magenta(name))
- header += fmt.Sprintf("Timestamp: %s\n", timestamp)
- if migration.Path != "" {
- header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path))
- }
- header += fmt.Sprintf("Status: %s\n", Red("⚠ Empty Migration"))
-
- // Show down migration availability (even for empty migrations)
- if migration.HasDownSQL {
- header += fmt.Sprintf("Down Migration: %s\n", Green("✓ Available"))
- } else {
- header += fmt.Sprintf("Down Migration: %s\n", Red("✗ Not available"))
- }
-
- header += "\nThis migration folder is empty or missing migration.sql.\n"
- header += "This may cause issues during deployment."
- d.content = header
- return
- }
-
- // Read migration.sql content
- sqlPath := filepath.Join(migration.Path, "migration.sql")
- content, err := os.ReadFile(sqlPath)
- if err != nil {
- timestamp, name := parseMigrationName(migration.Name)
- d.content = fmt.Sprintf("Name: %s\nTimestamp: %s\n\nError reading migration.sql:\n%v",
- name, timestamp, err)
- return
- }
-
- // Build header with status
- timestamp, name := parseMigrationName(migration.Name)
- var header string
- if migration.AppliedAt != nil {
- header = fmt.Sprintf("Name: %s\n", Green(name))
- header += fmt.Sprintf("Timestamp: %s\n", timestamp)
- if migration.Path != "" {
- header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path))
- }
- header += fmt.Sprintf("Status: %s (Applied at: %s)\n",
- Green("✓ Applied"),
- migration.AppliedAt.Format("2006-01-02 15:04:05"))
- } else {
- header = fmt.Sprintf("Name: %s\n", Yellow(name))
- header += fmt.Sprintf("Timestamp: %s\n", timestamp)
- if migration.Path != "" {
- header += fmt.Sprintf("Path: %s\n", getRelativePath(migration.Path))
- }
- header += fmt.Sprintf("Status: %s\n", Yellow("⚠ Pending"))
- }
-
- // Show down migration availability
- if migration.HasDownSQL {
- header += fmt.Sprintf("Down Migration: %s\n", Green("✓ Available"))
- } else {
- header += fmt.Sprintf("Down Migration: %s\n", Red("✗ Not available"))
- }
-
- // Apply syntax highlighting to SQL content
- highlightedSQL := highlightSQL(string(content))
-
- d.content = header + "\n" + highlightedSQL
-
- // Show down.sql if available
- if migration.HasDownSQL {
- downSQLPath := filepath.Join(migration.Path, "down.sql")
- downContent, err := os.ReadFile(downSQLPath)
- if err == nil {
- highlightedDownSQL := highlightSQL(string(downContent))
- d.content += "\n\n" + Yellow("Down Migration SQL:") + "\n\n" + highlightedDownSQL
- }
- }
-}
-
-// parseMigrationName parses a Prisma migration name into timestamp and description
-// Expected format: YYYYMMDDHHMMSS_description
-// Example: 20231123052950_create_career_table -> "2023-11-23 05:29:50", "create_career_table"
-func parseMigrationName(fullName string) (timestamp, name string) {
- // Check if name matches expected format (at least 15 chars with underscore at position 14)
- if len(fullName) > 15 && fullName[14] == '_' {
- timestampStr := fullName[:14] // "20231123052950"
- name = fullName[15:] // "create_career_table"
-
- // Parse timestamp: YYYYMMDDHHMMSS -> YYYY-MM-DD HH:MM:SS
- if len(timestampStr) == 14 {
- timestamp = fmt.Sprintf("%s-%s-%s %s:%s:%s",
- timestampStr[0:4], // YYYY
- timestampStr[4:6], // MM
- timestampStr[6:8], // DD
- timestampStr[8:10], // HH
- timestampStr[10:12], // mm
- timestampStr[12:14]) // ss
- return timestamp, name
- }
- }
-
- // Fallback: couldn't parse, return as-is
- return "N/A", fullName
-}
-
-// getRelativePath converts absolute path to relative path from current working directory
-func getRelativePath(absPath string) string {
- if absPath == "" {
- return ""
- }
-
- cwd, err := os.Getwd()
- if err != nil {
- return absPath // Fallback to absolute path
- }
-
- relPath, err := filepath.Rel(cwd, absPath)
- if err != nil {
- return absPath // Fallback to absolute path
- }
-
- return relPath
-}
-
-// LoadActionNeededData loads migrations that require action (Empty + Mismatch) and validates schema
-func (d *DetailsPanel) LoadActionNeededData() {
- if d.migrationsPanel == nil {
- d.actionNeededMigrations = []prisma.Migration{}
- d.validationResult = nil
- d.updateTabs()
- return
- }
-
- // Collect Empty and Mismatch migrations from Local category
- var actionNeeded []prisma.Migration
- for _, mig := range d.migrationsPanel.category.Local {
- if mig.IsEmpty || mig.ChecksumMismatch {
- actionNeeded = append(actionNeeded, mig)
- }
- }
-
- d.actionNeededMigrations = actionNeeded
-
- // Run schema validation
- cwd, err := os.Getwd()
- if err == nil {
- validateResult, err := prisma.Validate(cwd)
- if err == nil {
- d.validationResult = validateResult
- } else {
- d.validationResult = nil
- }
- } else {
- d.validationResult = nil
- }
-
- d.updateTabs()
-}
-
-// updateTabs rebuilds the tabs list based on available data
-func (d *DetailsPanel) updateTabs() {
- // Always have Details tab
- d.tabs = []string{"Details"}
-
- // Add Action-Needed tab if there are migration issues or validation errors
- hasIssues := len(d.actionNeededMigrations) > 0
- hasValidationErrors := d.validationResult != nil && !d.validationResult.Valid
-
- if hasIssues || hasValidationErrors {
- d.tabs = append(d.tabs, "Action-Needed")
- }
-
- // Reset tab index if current tab no longer exists
- if d.tabIndex >= len(d.tabs) {
- d.tabIndex = 0
- }
-}
-
-// saveCurrentTabState saves the current scroll position
-func (d *DetailsPanel) saveCurrentTabState() {
- if d.tabIndex >= len(d.tabs) {
- return
- }
- tabName := d.tabs[d.tabIndex]
- d.tabOriginY[tabName] = d.originY
-}
-
-// restoreTabState restores the scroll position for the current tab
-func (d *DetailsPanel) restoreTabState() {
- if d.tabIndex >= len(d.tabs) {
- return
- }
- tabName := d.tabs[d.tabIndex]
- if prevOriginY, exists := d.tabOriginY[tabName]; exists {
- d.originY = prevOriginY
- } else {
- d.originY = 0
- }
-}
-
-// NextTab switches to the next tab
-func (d *DetailsPanel) NextTab() {
- if len(d.tabs) == 0 {
- return
- }
- // Save current tab state before switching
- d.saveCurrentTabState()
-
- d.tabIndex = (d.tabIndex + 1) % len(d.tabs)
-
- // Restore scroll position for new tab
- d.restoreTabState()
-}
-
-// PrevTab switches to the previous tab
-func (d *DetailsPanel) PrevTab() {
- if len(d.tabs) == 0 {
- return
- }
- // Save current tab state before switching
- d.saveCurrentTabState()
-
- d.tabIndex = (d.tabIndex - 1 + len(d.tabs)) % len(d.tabs)
-
- // Restore scroll position for new tab
- d.restoreTabState()
-}
-
-// SetApp sets the app reference for modal checking
-func (d *DetailsPanel) SetApp(app *App) {
- d.app = app
-}
-
-// handleTabClick handles mouse click on tab bar
-func (d *DetailsPanel) handleTabClick(tabIndex int) error {
- // Ignore if modal is active
- if d.app != nil && d.app.HasActiveModal() {
- return nil
- }
-
- // First, switch focus to this panel if not already focused
- if d.app != nil {
- if err := d.app.handlePanelClick(ViewDetails); err != nil {
- return err
- }
- }
-
- // Ignore if same tab is clicked
- if tabIndex == d.tabIndex {
- return nil
- }
-
- // Ignore if tab index is out of bounds
- if tabIndex < 0 || tabIndex >= len(d.tabs) {
- return nil
- }
-
- // Save current tab state
- d.saveCurrentTabState()
-
- // Switch to clicked tab
- d.tabIndex = tabIndex
-
- // Restore scroll position for new tab
- d.restoreTabState()
-
- return nil
-}
diff --git a/pkg/app/generate_controller.go b/pkg/app/generate_controller.go
new file mode 100644
index 0000000..b1ef93a
--- /dev/null
+++ b/pkg/app/generate_controller.go
@@ -0,0 +1,186 @@
+package app
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/dokadev/lazyprisma/pkg/commands"
+ "github.com/dokadev/lazyprisma/pkg/gui/context"
+ "github.com/dokadev/lazyprisma/pkg/prisma"
+ "github.com/jesseduffield/gocui"
+)
+
+// Generate runs prisma generate and shows result in modal
+func (a *App) Generate() {
+ // Try to start command - if another command is running, block
+ if !a.tryStartCommand("Generate") {
+ a.logCommandBlocked("Generate")
+ return
+ }
+
+ outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext)
+ if !ok {
+ a.finishCommand() // Clean up if panel not found
+ return
+ }
+
+ // Get current working directory
+ cwd, err := os.Getwd()
+ if err != nil {
+ a.finishCommand()
+ outputPanel.LogAction(a.Tr.LogActionGenerateError, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateError,
+ a.Tr.ErrorFailedGetWorkingDir,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Log action start
+ outputPanel.LogAction(a.Tr.LogActionGenerate, a.Tr.LogMsgRunningGenerate)
+
+ // Create command builder
+ builder := commands.NewCommandBuilder(commands.NewPlatform())
+
+ // Build prisma generate command
+ generateCmd := builder.New("npx", "prisma", "generate").
+ WithWorkingDir(cwd).
+ StreamOutput().
+ OnStdout(func(line string) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.AppendOutput(" " + line)
+ }
+ return nil
+ })
+ }).
+ OnStderr(func(line string) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.AppendOutput(" " + line)
+ }
+ return nil
+ })
+ }).
+ OnComplete(func(exitCode int) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ if exitCode == 0 {
+ a.finishCommand() // Finish immediately on success
+ out.LogAction(a.Tr.LogActionGenerateComplete, a.Tr.LogMsgPrismaClientGeneratedSuccess)
+ // Show success modal
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateSuccess,
+ a.Tr.ModalMsgPrismaClientGenerated,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
+ a.OpenModal(modal)
+ } else {
+ // Failed - run validate to check schema (keep spinner running)
+ out.LogAction(a.Tr.LogActionGenerateFailed, a.Tr.LogMsgCheckingSchemaErrors)
+
+ // Run validate in goroutine to not block UI updates
+ go func() {
+ validateResult, err := prisma.Validate(cwd)
+
+ // Update UI on main thread after validate completes
+ a.g.Update(func(g *gocui.Gui) error {
+ a.finishCommand() // Finish after validate completes
+
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ if err == nil && !validateResult.Valid {
+ // Schema has validation errors - show them
+ out.LogAction(a.Tr.LogActionSchemaValidationFailed, fmt.Sprintf(a.Tr.LogMsgFoundSchemaErrors, len(validateResult.Errors)))
+
+ // Show validation errors in modal
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleSchemaValidationFailed,
+ a.Tr.ModalMsgGenerateFailedSchemaErrors,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ } else {
+ // Schema is valid but generate failed for other reasons
+ out.LogAction(a.Tr.LogActionGenerateFailed, fmt.Sprintf(a.Tr.ModalMsgGenerateFailedWithCode, exitCode))
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateFailed,
+ fmt.Sprintf(a.Tr.ModalMsgGenerateFailedWithCode, exitCode),
+ a.Tr.ModalMsgSchemaValidCheckOutput,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+ }
+ return nil
+ })
+ }()
+ }
+ }
+ return nil
+ })
+ }).
+ OnError(func(err error) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ // Check if it's an exit status error (command ran but failed)
+ if strings.Contains(err.Error(), "exit status") {
+ // Failed - run validate to check schema (keep spinner running)
+ out.LogAction(a.Tr.LogActionGenerateFailed, a.Tr.LogMsgCheckingSchemaErrors)
+
+ // Run validate in goroutine to not block UI updates
+ go func() {
+ validateResult, validateErr := prisma.Validate(cwd)
+
+ // Update UI on main thread after validate completes
+ a.g.Update(func(g *gocui.Gui) error {
+ a.finishCommand() // Finish after validate completes
+
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ if validateErr == nil && !validateResult.Valid {
+ // Schema has validation errors - show them
+ out.LogAction(a.Tr.LogActionSchemaValidationFailed, fmt.Sprintf(a.Tr.LogMsgFoundSchemaErrors, len(validateResult.Errors)))
+
+ // Show validation errors in modal
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleSchemaValidationFailed,
+ a.Tr.ModalMsgGenerateFailedSchemaErrors,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ } else {
+ // Schema is valid but generate failed for other reasons
+ out.LogAction(a.Tr.LogActionGenerateFailed, err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateFailed,
+ a.Tr.ModalMsgFailedRunGenerate,
+ a.Tr.ModalMsgSchemaValidCheckOutput,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+ }
+ return nil
+ })
+ }()
+ } else {
+ // Other error (command couldn't start, etc.)
+ a.finishCommand() // Finish immediately on startup error
+ out.LogAction(a.Tr.LogActionGenerateError, err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateError,
+ a.Tr.ModalMsgFailedRunGenerate,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+ }
+ return nil
+ })
+ })
+
+ // Run async to avoid blocking UI (spinner will show automatically)
+ if err := generateCmd.RunAsync(); err != nil {
+ a.finishCommand() // Clean up if command fails to start
+ outputPanel.LogAction(a.Tr.LogActionGenerateError, a.Tr.ModalMsgFailedStartGenerate+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleGenerateError,
+ a.Tr.ModalMsgFailedStartGenerate,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+}
diff --git a/pkg/app/input_modal.go b/pkg/app/input_modal.go
index 9edf429..4a6622a 100644
--- a/pkg/app/input_modal.go
+++ b/pkg/app/input_modal.go
@@ -3,19 +3,19 @@ package app
import (
"strings"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazycore/pkg/boxlayout"
)
// InputModal displays an input field for user text entry
type InputModal struct {
- g *gocui.Gui
+ *BaseModal
title string // Used as placeholder
subtitle string // Optional subtitle
footer string // Key bindings description
width int
height int
- style MessageModalStyle
onSubmit func(string)
onCancel func()
required bool
@@ -23,20 +23,19 @@ type InputModal struct {
}
// NewInputModal creates a new input modal
-func NewInputModal(g *gocui.Gui, title string, onSubmit func(string), onCancel func()) *InputModal {
+func NewInputModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, onSubmit func(string), onCancel func()) *InputModal {
return &InputModal{
- g: g,
- title: title,
- footer: " [Enter] Submit [ESC] Cancel ",
- style: MessageModalStyle{}, // Default style
- onSubmit: onSubmit,
- onCancel: onCancel,
+ BaseModal: NewBaseModal("input_modal", g, tr),
+ title: title,
+ footer: tr.ModalFooterInputSubmitCancel,
+ onSubmit: onSubmit,
+ onCancel: onCancel,
}
}
// WithStyle sets the modal style
func (m *InputModal) WithStyle(style MessageModalStyle) *InputModal {
- m.style = style
+ m.SetStyle(style)
return m
}
@@ -58,67 +57,33 @@ func (m *InputModal) OnValidationFail(callback func(string)) *InputModal {
return m
}
-// ID returns the modal's view ID
-func (m *InputModal) ID() string {
- return "input_modal"
-}
-
// Draw renders the input modal
func (m *InputModal) Draw(dim boxlayout.Dimensions) error {
- // Get screen size
- screenWidth, screenHeight := m.g.Size()
-
- // Calculate width (4/7 of screen, min 80)
- m.width = 4 * screenWidth / 7
- minWidth := 80
- if m.width < minWidth {
- if screenWidth-2 < minWidth {
- m.width = screenWidth - 2
- } else {
- m.width = minWidth
- }
- }
+ // Calculate width
+ m.width = m.CalculateDimensions(4.0/7.0, 80)
// Height for input modal: minimal single line
m.height = 2
// Center the modal
- x0 := (screenWidth - m.width) / 2
- y0 := (screenHeight - m.height) / 2
- x1 := x0 + m.width
- y1 := y0 + m.height
+ x0, y0, x1, y1 := m.CenterBox(m.width, m.height)
// Create input view
- v, err := m.g.SetView(m.ID(), x0, y0, x1, y1, 0)
- isNewView := err != nil
- if err != nil && err.Error() != "unknown view" {
+ v, isNew, err := m.SetupView(m.ID(), x0, y0, x1, y1, 0, " "+m.title+" ", m.footer)
+ if err != nil {
return err
}
// Only clear on first creation (TextArea manages content)
- if isNewView {
+ if isNew {
v.Clear()
// Initial render to make footer visible
v.RenderTextArea()
}
- v.Frame = true
- v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
- v.Title = " " + m.title + " "
if m.subtitle != "" {
v.Subtitle = " " + m.subtitle + " "
}
- v.Footer = m.footer
-
- // Apply frame color (border) if set
- if m.style.BorderColor != ColorDefault {
- v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor))
- }
-
- // Apply title color if set
- if m.style.TitleColor != ColorDefault {
- v.TitleColor = gocui.Attribute(colorToAnsiCode(m.style.TitleColor))
- }
// Input field settings (CRITICAL - DO NOT CHANGE)
v.Editable = true
@@ -156,7 +121,7 @@ func (m *InputModal) HandleKey(key any, mod gocui.Modifier) error {
// Validate if required
if m.required && input == "" {
if m.onValidationFail != nil {
- m.onValidationFail("Input is required")
+ m.onValidationFail(m.tr.ModalMsgInputRequired)
}
return nil // Don't submit
}
@@ -185,5 +150,5 @@ func (m *InputModal) OnClose() {
// Disable cursor at Gui level
m.g.Cursor = false
// Delete the input modal view
- m.g.DeleteView(m.ID())
+ m.BaseModal.OnClose()
}
diff --git a/pkg/app/keybinding.go b/pkg/app/keybinding.go
index 8b04573..c561f8d 100644
--- a/pkg/app/keybinding.go
+++ b/pkg/app/keybinding.go
@@ -1,6 +1,9 @@
package app
-import "github.com/jesseduffield/gocui"
+import (
+ "github.com/dokadev/lazyprisma/pkg/gui/context"
+ "github.com/jesseduffield/gocui"
+)
func (a *App) RegisterKeybindings() error {
// Quit or close modal (lowercase q)
@@ -19,17 +22,6 @@ func (a *App) RegisterKeybindings() error {
return err
}
- // Quit or close modal (uppercase Q)
- // if err := a.g.SetKeybinding("", 'Q', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error {
- // if a.HasActiveModal() {
- // a.CloseModal()
- // return nil
- // }
- // return gocui.ErrQuit
- // }); err != nil {
- // return err
- // }
-
// Ctrl+C to quit
if err := a.g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error {
return gocui.ErrQuit
@@ -56,9 +48,9 @@ func (a *App) RegisterKeybindings() error {
}
// Check if current panel supports tabs
if panel := a.GetCurrentPanel(); panel != nil {
- if migrationsPanel, ok := panel.(*MigrationsPanel); ok {
+ if migrationsPanel, ok := panel.(*context.MigrationsContext); ok {
migrationsPanel.NextTab()
- } else if detailsPanel, ok := panel.(*DetailsPanel); ok {
+ } else if detailsPanel, ok := panel.(*context.DetailsContext); ok {
detailsPanel.NextTab()
}
}
@@ -74,9 +66,9 @@ func (a *App) RegisterKeybindings() error {
}
// Check if current panel supports tabs
if panel := a.GetCurrentPanel(); panel != nil {
- if migrationsPanel, ok := panel.(*MigrationsPanel); ok {
+ if migrationsPanel, ok := panel.(*context.MigrationsContext); ok {
migrationsPanel.PrevTab()
- } else if detailsPanel, ok := panel.(*DetailsPanel); ok {
+ } else if detailsPanel, ok := panel.(*context.DetailsContext); ok {
detailsPanel.PrevTab()
}
}
@@ -114,13 +106,13 @@ func (a *App) RegisterKeybindings() error {
// Handle different panel types
if panel := a.GetCurrentPanel(); panel != nil {
switch p := panel.(type) {
- case *MigrationsPanel:
+ case *context.MigrationsContext:
p.SelectPrev()
- case *WorkspacePanel:
+ case *context.WorkspaceContext:
p.ScrollUp()
- case *DetailsPanel:
+ case *context.DetailsContext:
p.ScrollUp()
- case *OutputPanel:
+ case *context.OutputContext:
p.ScrollUp()
}
}
@@ -136,13 +128,13 @@ func (a *App) RegisterKeybindings() error {
// Handle different panel types
if panel := a.GetCurrentPanel(); panel != nil {
switch p := panel.(type) {
- case *MigrationsPanel:
+ case *context.MigrationsContext:
p.SelectNext()
- case *WorkspacePanel:
+ case *context.WorkspaceContext:
p.ScrollDown()
- case *DetailsPanel:
+ case *context.DetailsContext:
p.ScrollDown()
- case *OutputPanel:
+ case *context.OutputContext:
p.ScrollDown()
}
}
@@ -175,13 +167,13 @@ func (a *App) RegisterKeybindings() error {
// Handle different panel types
if panel := a.GetCurrentPanel(); panel != nil {
switch p := panel.(type) {
- case *MigrationsPanel:
+ case *context.MigrationsContext:
p.ScrollToTop()
- case *WorkspacePanel:
+ case *context.WorkspaceContext:
p.ScrollToTop()
- case *DetailsPanel:
+ case *context.DetailsContext:
p.ScrollToTop()
- case *OutputPanel:
+ case *context.OutputContext:
p.ScrollToTop()
}
}
@@ -198,13 +190,13 @@ func (a *App) RegisterKeybindings() error {
// Handle different panel types
if panel := a.GetCurrentPanel(); panel != nil {
switch p := panel.(type) {
- case *MigrationsPanel:
+ case *context.MigrationsContext:
p.ScrollToBottom()
- case *WorkspacePanel:
+ case *context.WorkspaceContext:
p.ScrollToBottom()
- case *DetailsPanel:
+ case *context.DetailsContext:
p.ScrollToBottom()
- case *OutputPanel:
+ case *context.OutputContext:
p.ScrollToBottom()
}
}
@@ -224,39 +216,6 @@ func (a *App) RegisterKeybindings() error {
return err
}
- // 'i' key - test ping to google.com
- if err := a.g.SetKeybinding("", 'i', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error {
- if a.HasActiveModal() {
- return nil
- }
- a.TestPing()
- return nil
- }); err != nil {
- return err
- }
-
- // // 't' key - test modal (temporary)
- // if err := a.g.SetKeybinding("", 't', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error {
- // if a.HasActiveModal() {
- // return nil
- // }
- // a.TestModal()
- // return nil
- // }); err != nil {
- // return err
- // }
-
- // // 'm' key - test input modal (temporary)
- // if err := a.g.SetKeybinding("", 'm', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error {
- // if a.HasActiveModal() {
- // return nil
- // }
- // a.TestInputModal()
- // return nil
- // }); err != nil {
- // return err
- // }
-
// 'd' key - migrate dev
if err := a.g.SetKeybinding("", 'd', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error {
if a.HasActiveModal() {
@@ -364,28 +323,5 @@ func (a *App) RegisterKeybindings() error {
return err
}
- // // 'l' key - test list modal (temporary)
- // if err := a.g.SetKeybinding("", 'l', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error {
- // if a.HasActiveModal() {
- // return nil
- // }
- // a.TestListModal()
- // return nil
- // }); err != nil {
- // return err
- // }
-
- // // 'y' key - test confirm modal (temporary)
- // if err := a.g.SetKeybinding("", 'y', gocui.ModNone, func(g *gocui.Gui, v *gocui.View) error {
- // if a.HasActiveModal() {
- // // Pass 'y' to modal (for ConfirmModal)
- // return a.activeModal.HandleKey('y', gocui.ModNone)
- // }
- // a.TestConfirmModal()
- // return nil
- // }); err != nil {
- // return err
- // }
-
return nil
}
diff --git a/pkg/app/layout.go b/pkg/app/layout.go
index 3808d4e..f200dd0 100644
--- a/pkg/app/layout.go
+++ b/pkg/app/layout.go
@@ -21,7 +21,7 @@ func (a *App) layoutManager(g *gocui.Gui) error {
Children: []*boxlayout.Box{
{
Window: ViewWorkspace,
- Size: 10, // 실제 컨텐츠 길이 확인 후 재조정 필요
+ Size: 10, // May need readjusting based on actual content length
},
{
Window: ViewMigrations,
@@ -52,10 +52,10 @@ func (a *App) layoutManager(g *gocui.Gui) error {
},
}
- // boxlayout으로 차원 계산
+ // Calculate dimensions via boxlayout
dimensionMap := boxlayout.ArrangeWindows(root, 0, 0, width, height)
- // 각 패널 렌더링
+ // Render each panel
for id, dim := range dimensionMap {
if panel, ok := a.panels[id]; ok {
if err := panel.Draw(dim); err != nil {
diff --git a/pkg/app/list_modal.go b/pkg/app/list_modal.go
index 900972e..d408eb4 100644
--- a/pkg/app/list_modal.go
+++ b/pkg/app/list_modal.go
@@ -2,55 +2,48 @@ package app
import (
"fmt"
- "strings"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazycore/pkg/boxlayout"
)
// ListModalItem represents a selectable item in the list modal
type ListModalItem struct {
- Label string // Display text in the list
- Description string // Description shown in the bottom view
+ Label string // Display text in the list
+ Description string // Description shown in the bottom view
OnSelect func() error // Callback when item is selected with Enter
}
// ListModal displays a list of items with descriptions
type ListModal struct {
- g *gocui.Gui
- title string
- items []ListModalItem
- selectedIdx int
- originY int // Scroll position for list view
- width int
- height int
- style MessageModalStyle
- onCancel func()
+ *BaseModal
+ title string
+ items []ListModalItem
+ selectedIdx int
+ originY int // Scroll position for list view
+ width int
+ height int
+ onCancel func()
}
// NewListModal creates a new list modal
-func NewListModal(g *gocui.Gui, title string, items []ListModalItem, onCancel func()) *ListModal {
+func NewListModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, items []ListModalItem, onCancel func()) *ListModal {
return &ListModal{
- g: g,
+ BaseModal: NewBaseModal("list_modal", g, tr),
title: title,
items: items,
selectedIdx: 0,
- style: MessageModalStyle{}, // Default style
onCancel: onCancel,
}
}
// WithStyle sets the modal style
func (m *ListModal) WithStyle(style MessageModalStyle) *ListModal {
- m.style = style
+ m.SetStyle(style)
return m
}
-// ID returns the modal's view ID
-func (m *ListModal) ID() string {
- return "list_modal"
-}
-
// listViewID returns the list view ID
func (m *ListModal) listViewID() string {
return "list_modal_list"
@@ -63,26 +56,15 @@ func (m *ListModal) descViewID() string {
// Draw renders the list modal with two views (list on top, description on bottom)
func (m *ListModal) Draw(dim boxlayout.Dimensions) error {
- // Get screen size
- screenWidth, screenHeight := m.g.Size()
-
// Calculate width (5/7 of screen, min 80)
- m.width = 5 * screenWidth / 7
- minWidth := 80
- if m.width < minWidth {
- if screenWidth-2 < minWidth {
- m.width = screenWidth - 2
- } else {
- m.width = minWidth
- }
- }
+ m.width = m.CalculateDimensions(5.0/7.0, 80)
// Calculate description height dynamically based on selected item's description
availableWidth := m.width - 4 // Minus frame and padding
var descContentLines int
if m.selectedIdx >= 0 && m.selectedIdx < len(m.items) {
desc := m.items[m.selectedIdx].Description
- wrappedLines := m.wrapText(desc, availableWidth)
+ wrappedLines := WrapText(desc, availableWidth, "")
descContentLines = len(wrappedLines)
}
@@ -95,6 +77,8 @@ func (m *ListModal) Draw(dim boxlayout.Dimensions) error {
m.height = listHeight + descHeight + 1 // +1 for gap
// Don't exceed screen height
+ screenWidth, screenHeight := m.g.Size()
+ _ = screenWidth
maxHeight := screenHeight - 4
if m.height > maxHeight {
m.height = maxHeight
@@ -108,8 +92,7 @@ func (m *ListModal) Draw(dim boxlayout.Dimensions) error {
}
// Center the modal
- x0 := (screenWidth - m.width) / 2
- y0 := (screenHeight - m.height) / 2
+ x0, y0, _, _ := m.CenterBox(m.width, m.height)
// Draw list view (top)
listX0 := x0
@@ -136,27 +119,12 @@ func (m *ListModal) Draw(dim boxlayout.Dimensions) error {
// drawListView renders the list view (top)
func (m *ListModal) drawListView(x0, y0, x1, y1 int) error {
- v, err := m.g.SetView(m.listViewID(), x0, y0, x1, y1, 0)
- if err != nil && err.Error() != "unknown view" {
+ v, _, err := m.SetupView(m.listViewID(), x0, y0, x1, y1, 0, " "+m.title+" ", "")
+ if err != nil {
return err
}
v.Clear()
- v.Frame = true
- v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
- v.Title = " " + m.title + " "
- v.Footer = ""
-
- // Apply frame color (border) if set
- if m.style.BorderColor != ColorDefault {
- v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor))
- }
-
- // Apply title color if set
- if m.style.TitleColor != ColorDefault {
- v.TitleColor = gocui.Attribute(colorToAnsiCode(m.style.TitleColor))
- }
-
v.Wrap = false
// Enable highlight for selection (like MigrationsPanel)
@@ -180,22 +148,12 @@ func (m *ListModal) drawListView(x0, y0, x1, y1 int) error {
// drawDescView renders the description view (bottom)
func (m *ListModal) drawDescView(x0, y0, x1, y1 int) error {
- v, err := m.g.SetView(m.descViewID(), x0, y0, x1, y1, 0)
- if err != nil && err.Error() != "unknown view" {
+ v, _, err := m.SetupView(m.descViewID(), x0, y0, x1, y1, 0, "", m.tr.ModalFooterListNavigate)
+ if err != nil {
return err
}
v.Clear()
- v.Frame = true
- v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
- v.Title = ""
- v.Footer = " [↑/↓] Navigate [Enter] Select [ESC] Cancel "
-
- // Apply frame color (border) if set
- if m.style.BorderColor != ColorDefault {
- v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor))
- }
-
v.Wrap = true
// Render description for selected item
@@ -204,7 +162,7 @@ func (m *ListModal) drawDescView(x0, y0, x1, y1 int) error {
// Word wrap description
availableWidth := (x1 - x0) - 4 // Minus frame and padding
- wrappedLines := m.wrapText(desc, availableWidth)
+ wrappedLines := WrapText(desc, availableWidth, "")
for _, line := range wrappedLines {
fmt.Fprintln(v, " "+line)
@@ -214,52 +172,6 @@ func (m *ListModal) drawDescView(x0, y0, x1, y1 int) error {
return nil
}
-// wrapText wraps text to fit within the specified width
-func (m *ListModal) wrapText(text string, width int) []string {
- if width <= 0 {
- return []string{text}
- }
-
- var lines []string
- paragraphs := strings.Split(text, "\n")
-
- for _, para := range paragraphs {
- if len(para) == 0 {
- lines = append(lines, "")
- continue
- }
-
- if len(para) <= width {
- lines = append(lines, para)
- } else {
- // Simple word wrapping
- words := strings.Fields(para)
- currentLine := ""
-
- for _, word := range words {
- if len(currentLine)+len(word)+1 <= width {
- if currentLine == "" {
- currentLine = word
- } else {
- currentLine += " " + word
- }
- } else {
- // Current line is full, start new line
- lines = append(lines, currentLine)
- currentLine = word
- }
- }
-
- // Add remaining line
- if currentLine != "" {
- lines = append(lines, currentLine)
- }
- }
- }
-
- return lines
-}
-
// HandleKey handles keyboard input
func (m *ListModal) HandleKey(key any, mod gocui.Modifier) error {
switch key {
diff --git a/pkg/app/message_modal.go b/pkg/app/message_modal.go
index 971c01c..bced1ee 100644
--- a/pkg/app/message_modal.go
+++ b/pkg/app/message_modal.go
@@ -2,8 +2,8 @@ package app
import (
"fmt"
- "strings"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazycore/pkg/boxlayout"
)
@@ -16,95 +16,50 @@ type MessageModalStyle struct {
// MessageModal displays a message with title and content
type MessageModal struct {
- g *gocui.Gui
+ *BaseModal
title string
contentLines []string // Original content lines
lines []string // Wrapped content lines
width int
height int
- style MessageModalStyle
}
// NewMessageModal creates a new message modal
-func NewMessageModal(g *gocui.Gui, title string, lines ...string) *MessageModal {
+func NewMessageModal(g *gocui.Gui, tr *i18n.TranslationSet, title string, lines ...string) *MessageModal {
return &MessageModal{
- g: g,
+ BaseModal: NewBaseModal("modal", g, tr),
title: title,
contentLines: lines,
- style: MessageModalStyle{}, // Default style
}
}
// WithStyle sets the modal style
func (m *MessageModal) WithStyle(style MessageModalStyle) *MessageModal {
- m.style = style
+ m.SetStyle(style)
return m
}
-// ID returns the modal's view ID
-func (m *MessageModal) ID() string {
- return "modal"
-}
-
// Draw renders the modal
func (m *MessageModal) Draw(dim boxlayout.Dimensions) error {
- // Get screen size
- screenWidth, screenHeight := m.g.Size()
-
- // Calculate width (4/7 of screen, min 80)
- m.width = 4 * screenWidth / 7
- minWidth := 80
- if m.width < minWidth {
- if screenWidth-2 < minWidth {
- m.width = screenWidth - 2
- } else {
- m.width = minWidth
- }
- }
+ // Calculate width
+ m.width = m.CalculateDimensions(4.0/7.0, 80)
// Parse content into lines and calculate required height
m.parseContent()
// Calculate height based on content
- // Content + 2 (top and bottom borders with title/footer)
- contentHeight := len(m.lines)
- // m.height = contentHeight + 2
- m.height = contentHeight + 1
-
- // Don't exceed screen height
- maxHeight := screenHeight - 4
- if m.height > maxHeight {
- m.height = maxHeight
- }
+ m.height = len(m.lines) + 1
// Center the modal
- x0 := (screenWidth - m.width) / 2
- y0 := (screenHeight - m.height) / 2
- x1 := x0 + m.width
- y1 := y0 + m.height
+ x0, y0, x1, y1 := m.CenterBox(m.width, m.height)
// Create modal view
- v, err := m.g.SetView(m.ID(), x0, y0, x1, y1, 0)
- if err != nil && err.Error() != "unknown view" {
+ v, _, err := m.SetupView(m.ID(), x0, y0, x1, y1, 0, " "+m.title+" ", m.tr.ModalFooterMessageClose)
+ if err != nil {
return err
}
v.Clear()
- v.Frame = true
- v.FrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
- v.Title = " " + m.title + " "
- v.Footer = " [Enter/q/ESC] Close "
-
- // Apply frame color (border) if set
- if m.style.BorderColor != ColorDefault {
- v.FrameColor = gocui.Attribute(colorToAnsiCode(m.style.BorderColor))
- }
-
- // Apply title color if set
- if m.style.TitleColor != ColorDefault {
- v.TitleColor = gocui.Attribute(colorToAnsiCode(m.style.TitleColor))
- }
-
v.Wrap = false
// Render content
@@ -128,33 +83,8 @@ func (m *MessageModal) parseContent() {
continue
}
- // Word wrap long lines
- if len(line) <= availableWidth {
- m.lines = append(m.lines, " "+line)
- } else {
- // Simple word wrapping
- words := strings.Fields(line)
- currentLine := " "
-
- for _, word := range words {
- if len(currentLine)+len(word)+1 <= availableWidth+2 { // +2 for initial " "
- if currentLine == " " {
- currentLine += word
- } else {
- currentLine += " " + word
- }
- } else {
- // Current line is full, start new line
- m.lines = append(m.lines, currentLine)
- currentLine = " " + word
- }
- }
-
- // Add remaining line
- if currentLine != " " {
- m.lines = append(m.lines, currentLine)
- }
- }
+ wrapped := WrapText(line, availableWidth, " ")
+ m.lines = append(m.lines, wrapped...)
}
}
@@ -171,30 +101,6 @@ func (m *MessageModal) HandleKey(key any, mod gocui.Modifier) error {
// OnClose is called when the modal is closed
func (m *MessageModal) OnClose() {
- // Delete the modal view
- m.g.DeleteView(m.ID())
+ m.BaseModal.OnClose()
}
-// colorToAnsiCode converts Color to gocui color attribute
-func colorToAnsiCode(c Color) int {
- switch c {
- case ColorBlack:
- return int(gocui.ColorBlack)
- case ColorRed:
- return int(gocui.ColorRed)
- case ColorGreen:
- return int(gocui.ColorGreen)
- case ColorYellow:
- return int(gocui.ColorYellow)
- case ColorBlue:
- return int(gocui.ColorBlue)
- case ColorMagenta:
- return int(gocui.ColorMagenta)
- case ColorCyan:
- return int(gocui.ColorCyan)
- case ColorWhite:
- return int(gocui.ColorWhite)
- default:
- return int(gocui.ColorDefault)
- }
-}
diff --git a/pkg/app/migrations.go b/pkg/app/migrations.go
deleted file mode 100644
index e6fdbcb..0000000
--- a/pkg/app/migrations.go
+++ /dev/null
@@ -1,632 +0,0 @@
-package app
-
-import (
- "fmt"
- "os"
- "strings"
-
- "github.com/dokadev/lazyprisma/pkg/database"
- "github.com/dokadev/lazyprisma/pkg/prisma"
- "github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazycore/pkg/boxlayout"
-)
-
-type MigrationsPanel struct {
- BasePanel
- category prisma.MigrationCategory // Categorized migrations
- items []string // Current tab's migration names
- tabs []string // Tab names (conditional)
- tabIndex int // Current tab index
- selected int // Selected item in current tab
- originY int // Scroll position
- dbClient *database.Client // Database connection
- dbConnected bool // True if connected to database
- tableExists bool // True if _prisma_migrations table exists
-
- // Tab state preservation
- tabSelected map[string]int // Last selected index per tab
- tabOriginY map[string]int // Last scroll position per tab
-
- // Details panel reference
- detailsPanel *DetailsPanel
-
- // App reference for modal check
- app *App
-}
-
-func NewMigrationsPanel(g *gocui.Gui) *MigrationsPanel {
- panel := &MigrationsPanel{
- BasePanel: NewBasePanel(ViewMigrations, g),
- items: []string{},
- tabs: []string{},
- tabIndex: 0,
- selected: 0,
- tabSelected: make(map[string]int),
- tabOriginY: make(map[string]int),
- }
- panel.loadMigrations()
- return panel
-}
-
-func (m *MigrationsPanel) loadMigrations() {
- cwd, err := os.Getwd()
- if err != nil {
- m.items = []string{"Error: Failed to get working directory"}
- m.tabs = []string{"Local"}
- return
- }
-
- // Get local migrations
- localMigrations, err := prisma.GetLocalMigrations(cwd)
- if err != nil {
- m.items = []string{fmt.Sprintf("Error loading local migrations: %v", err)}
- m.tabs = []string{"Local"}
- return
- }
-
- // Try to connect to database
- ds, err := prisma.GetDatasource(cwd)
- var dbMigrations []prisma.DBMigration
- m.dbConnected = false
- tableExists := false
-
- if err == nil && ds.URL != "" {
- client, err := database.NewClientFromDSN(ds.Provider, ds.URL)
- if err == nil {
- m.dbClient = client
- dbMigrations, err = prisma.GetDBMigrations(client.DB())
- if err == nil {
- m.dbConnected = true
- tableExists = true
- } else {
- // Check if error is due to missing table
- if isMissingTableError(err) {
- // Table doesn't exist - treat as empty DB (all migrations are pending)
- m.dbConnected = true
- tableExists = false
- dbMigrations = []prisma.DBMigration{} // Empty list
- }
- // Other errors: keep m.dbConnected = false
- }
- }
- }
-
- // If DB is connected (or table doesn't exist), categorize migrations
- if m.dbConnected {
- m.category = prisma.CompareMigrations(localMigrations, dbMigrations)
-
- // Build tabs based on available data
- m.tabs = []string{"Local"}
- if len(m.category.Pending) > 0 {
- m.tabs = append(m.tabs, "Pending")
- }
- if len(m.category.DBOnly) > 0 {
- m.tabs = append(m.tabs, "DB-Only")
- }
-
- // Store table existence info for display
- m.tableExists = tableExists
- } else {
- // DB connection failed completely
- m.category = prisma.MigrationCategory{
- Local: localMigrations,
- Pending: []prisma.Migration{},
- DBOnly: []prisma.Migration{},
- }
- m.tabs = []string{"Local"}
- m.tableExists = false
- }
-
- // Load items for current tab (default: Local)
- m.tabIndex = 0
- m.loadItemsForCurrentTab()
-}
-
-func (m *MigrationsPanel) loadItemsForCurrentTab() {
- if m.tabIndex >= len(m.tabs) {
- m.items = []string{}
- return
- }
-
- tabName := m.tabs[m.tabIndex]
- var migrations []prisma.Migration
-
- switch tabName {
- case "Local":
- migrations = m.category.Local
- case "Pending":
- migrations = m.category.Pending
- case "DB-Only":
- migrations = m.category.DBOnly
- }
-
- if len(migrations) == 0 {
- m.items = []string{"No migrations found"}
- return
- }
-
- m.items = make([]string, len(migrations))
- for i, mig := range migrations {
- // Parse migration name to show only description (without timestamp)
- displayName := mig.Name
- if len(mig.Name) > 15 && mig.Name[14] == '_' {
- displayName = mig.Name[15:] // Skip YYYYMMDDHHMMSS_ prefix
- }
-
- // Add index number with color based on migration status
- var indexPrefix string
- if mig.IsEmpty {
- indexPrefix = fmt.Sprintf("\033[31m%4d │\033[0m ", i+1) // Red for empty migrations (no migration.sql)
- } else if mig.HasDownSQL {
- indexPrefix = fmt.Sprintf("\033[32m%4d │\033[0m ", i+1) // Green for migrations with down.sql
- } else {
- indexPrefix = fmt.Sprintf("\033[90m%4d │\033[0m ", i+1) // Gray for normal migrations
- }
-
- // Color priority: Failed > Checksum Mismatch > Empty > Pending > Normal
- if mig.IsFailed {
- // In-Transaction migrations (finished_at IS NULL AND rolled_back_at IS NULL) are shown in cyan
- m.items[i] = indexPrefix + Cyan(displayName)
- } else if mig.ChecksumMismatch {
- // Checksum mismatch migrations are shown in orange
- m.items[i] = indexPrefix + Orange(displayName)
- } else if mig.IsEmpty {
- // Empty migrations (no migration.sql) are shown in red
- m.items[i] = indexPrefix + Red(displayName)
- } else if m.dbConnected && mig.AppliedAt == nil {
- // Pending migrations (not applied to DB) are shown in yellow
- // Only when DB is connected (otherwise we can't determine pending status)
- m.items[i] = indexPrefix + Yellow(displayName)
- } else {
- m.items[i] = indexPrefix + displayName
- }
- }
-
- // Restore previous selection and scroll position for this tab
- if prevSelected, exists := m.tabSelected[tabName]; exists {
- m.selected = prevSelected
- // Ensure selection is within bounds
- if m.selected >= len(m.items) {
- m.selected = len(m.items) - 1
- }
- if m.selected < 0 {
- m.selected = 0
- }
- } else {
- m.selected = 0
- }
-
- if prevOriginY, exists := m.tabOriginY[tabName]; exists {
- m.originY = prevOriginY
- } else {
- m.originY = 0
- }
-
- // Update details panel
- m.updateDetails()
-}
-
-func (m *MigrationsPanel) Draw(dim boxlayout.Dimensions) error {
- v, err := m.g.SetView(m.id, dim.X0, dim.Y0, dim.X1, dim.Y1, 0)
- if err != nil && err.Error() != "unknown view" {
- return err
- }
-
- // Setup view WITHOUT title (tabs replace title)
- m.v = v
- v.Clear()
- v.Frame = true
- v.FrameRunes = m.frameRunes
-
- // Set tabs
- v.Tabs = m.tabs
- v.TabIndex = m.tabIndex
-
- // Set footer based on current tab (moved from subtitle)
- if m.tabIndex < len(m.tabs) {
- // Footer (n of n) for all tabs
- footer := m.buildFooter()
- v.Footer = footer
- } else {
- v.Footer = ""
- }
-
- // No subtitle
- v.Subtitle = ""
-
- // Set frame and tab colors based on focus
- if m.focused {
- v.FrameColor = FocusedFrameColor
- v.TitleColor = FocusedTitleColor
- if len(m.tabs) == 1 {
- v.SelFgColor = FocusedTitleColor // Single tab: treat like title
- } else {
- v.SelFgColor = FocusedActiveTabColor // Multiple tabs: use active tab color
- }
- } else {
- v.FrameColor = PrimaryFrameColor
- v.TitleColor = PrimaryTitleColor
- if len(m.tabs) == 1 {
- v.SelFgColor = PrimaryTitleColor // Single tab: treat like title
- } else {
- v.SelFgColor = PrimaryActiveTabColor // Multiple tabs: use active tab color
- }
- }
-
- // Enable highlight for selection
- v.Highlight = true
- v.SelBgColor = SelectionBgColor
-
- // Render items
- for _, item := range m.items {
- fmt.Fprintln(v, item)
- }
-
- // Adjust origin to ensure it's within valid bounds
- AdjustOrigin(v, &m.originY)
-
- // Set cursor position to selected item
- v.SetCursor(0, m.selected-m.originY)
- v.SetOrigin(0, m.originY)
-
- return nil
-}
-
-// buildFooter builds the footer text (selection info in "n of n" format)
-func (m *MigrationsPanel) buildFooter() string {
- // Don't show footer if no valid items
- if len(m.items) == 0 || (len(m.items) == 1 && m.items[0] == "No migrations found") {
- return ""
- }
-
- // Show selection info: "2 of 5"
- return fmt.Sprintf("%d of %d", m.selected+1, len(m.items))
-}
-
-func (m *MigrationsPanel) SelectNext() {
- if len(m.items) == 0 {
- return
- }
-
- if m.selected < len(m.items)-1 {
- m.selected++
-
- // Auto-scroll if needed
- if m.v != nil {
- _, h := m.v.Size()
- innerHeight := h - 2 // Subtract frame borders
- if m.selected-m.originY >= innerHeight {
- m.originY++
- }
- }
-
- // Update details panel
- m.updateDetails()
- }
-}
-
-func (m *MigrationsPanel) SelectPrev() {
- if len(m.items) == 0 {
- return
- }
-
- if m.selected > 0 {
- m.selected--
-
- // Auto-scroll if needed
- if m.selected < m.originY {
- m.originY--
- }
-
- // Update details panel
- m.updateDetails()
- }
-}
-
-// ScrollUpByWheel scrolls the migrations list up by 2 lines (mouse wheel)
-func (m *MigrationsPanel) ScrollUpByWheel() {
- if m.originY > 0 {
- m.originY -= 2
- if m.originY < 0 {
- m.originY = 0
- }
- }
-}
-
-// ScrollDownByWheel scrolls the migrations list down by 2 lines (mouse wheel)
-func (m *MigrationsPanel) ScrollDownByWheel() {
- if m.v == nil || len(m.items) == 0 {
- return
- }
-
- // Get actual content lines
- contentLines := len(m.items)
- _, viewHeight := m.v.Size()
- innerHeight := viewHeight - 2 // Exclude frame (top + bottom)
-
- // Calculate maxOrigin
- maxOrigin := contentLines - innerHeight
- if maxOrigin < 0 {
- maxOrigin = 0
- }
-
- // Only scroll if we haven't reached the bottom
- if m.originY < maxOrigin {
- m.originY += 2
- if m.originY > maxOrigin {
- m.originY = maxOrigin
- }
- }
-}
-
-// ScrollToTop scrolls to the top of the migrations list
-func (m *MigrationsPanel) ScrollToTop() {
- if len(m.items) == 0 {
- return
- }
-
- m.selected = 0
- m.originY = 0
- m.updateDetails()
-}
-
-// ScrollToBottom scrolls to the bottom of the migrations list
-func (m *MigrationsPanel) ScrollToBottom() {
- if len(m.items) == 0 {
- return
- }
-
- maxIndex := len(m.items) - 1
- m.selected = maxIndex
-
- // Adjust origin to show the last item
- if m.v != nil {
- _, h := m.v.Size()
- innerHeight := h - 2 // Subtract frame borders
- m.originY = maxIndex - innerHeight + 1
- if m.originY < 0 {
- m.originY = 0
- }
- }
-
- m.updateDetails()
-}
-
-// NextTab switches to the next tab
-func (m *MigrationsPanel) NextTab() {
- if len(m.tabs) == 0 {
- return
- }
-
- // Save current tab state before switching
- m.saveCurrentTabState()
-
- m.tabIndex = (m.tabIndex + 1) % len(m.tabs)
- m.loadItemsForCurrentTab()
-}
-
-// PrevTab switches to the previous tab
-func (m *MigrationsPanel) PrevTab() {
- if len(m.tabs) == 0 {
- return
- }
-
- // Save current tab state before switching
- m.saveCurrentTabState()
-
- m.tabIndex = (m.tabIndex - 1 + len(m.tabs)) % len(m.tabs)
- m.loadItemsForCurrentTab()
-}
-
-// saveCurrentTabState saves the current selection and scroll position
-func (m *MigrationsPanel) saveCurrentTabState() {
- if m.tabIndex >= len(m.tabs) {
- return
- }
-
- tabName := m.tabs[m.tabIndex]
- m.tabSelected[tabName] = m.selected
- m.tabOriginY[tabName] = m.originY
-}
-
-// GetSelectedMigration returns the currently selected migration
-func (m *MigrationsPanel) GetSelectedMigration() *prisma.Migration {
- if m.tabIndex >= len(m.tabs) {
- return nil
- }
-
- tabName := m.tabs[m.tabIndex]
- var migrations []prisma.Migration
-
- switch tabName {
- case "Local":
- migrations = m.category.Local
- case "Pending":
- migrations = m.category.Pending
- case "DB-Only":
- migrations = m.category.DBOnly
- }
-
- if m.selected >= 0 && m.selected < len(migrations) {
- return &migrations[m.selected]
- }
-
- return nil
-}
-
-// GetCurrentTab returns the name of the current tab
-func (m *MigrationsPanel) GetCurrentTab() string {
- if m.tabIndex >= len(m.tabs) {
- return ""
- }
- return m.tabs[m.tabIndex]
-}
-
-// SetDetailsPanel sets the details panel reference and performs initial update
-func (m *MigrationsPanel) SetDetailsPanel(details *DetailsPanel) {
- m.detailsPanel = details
- // Set bidirectional reference
- details.migrationsPanel = m
- // Load Action-Needed data
- details.LoadActionNeededData()
- // Update details with initial selection (index 0)
- m.updateDetails()
-}
-
-// updateDetails updates the details panel with current selection
-func (m *MigrationsPanel) updateDetails() {
- if m.detailsPanel == nil {
- return
- }
-
- migration := m.GetSelectedMigration()
- tabName := m.GetCurrentTab()
- m.detailsPanel.UpdateFromMigration(migration, tabName)
-}
-
-// isMissingTableError checks if error is due to missing _prisma_migrations table
-func isMissingTableError(err error) bool {
- if err == nil {
- return false
- }
-
- errMsg := strings.ToLower(err.Error())
-
- // Common error patterns across different databases
- missingTablePatterns := []string{
- "does not exist", // PostgreSQL
- "doesn't exist", // MySQL
- "no such table", // SQLite
- "invalid object name", // SQL Server
- "table or view does not exist", // Oracle
- }
-
- for _, pattern := range missingTablePatterns {
- if strings.Contains(errMsg, pattern) {
- return true
- }
- }
-
- return false
-}
-
-// SetApp sets the app reference for modal checking
-func (m *MigrationsPanel) SetApp(app *App) {
- m.app = app
-}
-
-// handleTabClick handles mouse click on tab bar
-func (m *MigrationsPanel) handleTabClick(tabIndex int) error {
- // Ignore if modal is active
- if m.app != nil && m.app.HasActiveModal() {
- return nil
- }
-
- // First, switch focus to this panel if not already focused
- if m.app != nil {
- if err := m.app.handlePanelClick(ViewMigrations); err != nil {
- return err
- }
- }
-
- // Ignore if same tab is clicked
- if tabIndex == m.tabIndex {
- return nil
- }
-
- // Ignore if tab index is out of bounds
- if tabIndex < 0 || tabIndex >= len(m.tabs) {
- return nil
- }
-
- // Save current tab state
- m.saveCurrentTabState()
-
- // Switch to clicked tab
- m.tabIndex = tabIndex
- m.loadItemsForCurrentTab()
-
- return nil
-}
-
-// handleListClick handles mouse click on list item
-func (m *MigrationsPanel) handleListClick(y int) error {
- // Ignore if modal is active
- if m.app != nil && m.app.HasActiveModal() {
- return nil
- }
-
- // Ignore if no items
- if len(m.items) == 0 {
- return nil
- }
-
- // opts.Y is already content-relative index (including origin)
- clickedIndex := y
-
- // Validate index
- if clickedIndex < 0 || clickedIndex >= len(m.items) {
- return nil
- }
-
- // Update selected index
- m.selected = clickedIndex
-
- // Update details panel
- m.updateDetails()
-
- // Switch focus to this panel if not already focused
- if m.app != nil {
- if err := m.app.handlePanelClick(ViewMigrations); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-// Refresh reloads all migration data
-func (m *MigrationsPanel) Refresh() {
- // Save current state to restore after refresh
- currentTabIndex := m.tabIndex
- currentSelected := m.selected
- currentOriginY := m.originY
-
- // Save current tab state before refresh (to prevent loadItemsForCurrentTab from resetting selection)
- if currentTabIndex < len(m.tabs) {
- currentTabName := m.tabs[currentTabIndex]
- m.tabSelected[currentTabName] = currentSelected
- m.tabOriginY[currentTabName] = currentOriginY
- }
-
- // Reload migrations
- m.loadMigrations()
-
- // Restore tab index if still valid
- if currentTabIndex < len(m.tabs) {
- m.tabIndex = currentTabIndex
- } else {
- // Reset to first tab if current tab no longer exists
- m.tabIndex = 0
- }
-
- // Reload items for current tab
- m.loadItemsForCurrentTab()
-
- // Restore selection if still valid
- if currentSelected < len(m.items) {
- m.selected = currentSelected
- m.originY = currentOriginY
- // Only update details if selection changed
- m.updateDetails()
- } else if len(m.items) > 0 {
- // If old selection is invalid, select last valid item
- m.selected = len(m.items) - 1
- m.originY = 0
- m.updateDetails()
- } else {
- // No items, reset
- m.selected = 0
- m.originY = 0
- }
-}
diff --git a/pkg/app/migrations_controller.go b/pkg/app/migrations_controller.go
new file mode 100644
index 0000000..9d04a37
--- /dev/null
+++ b/pkg/app/migrations_controller.go
@@ -0,0 +1,781 @@
+package app
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/dokadev/lazyprisma/pkg/commands"
+ "github.com/dokadev/lazyprisma/pkg/gui/context"
+ "github.com/jesseduffield/gocui"
+)
+
+// MigrateDeploy runs npx prisma migrate deploy
+func (a *App) MigrateDeploy() {
+ // Try to start command - if another command is running, block
+ if !a.tryStartCommand("Migrate Deploy") {
+ a.logCommandBlocked("Migrate Deploy")
+ return
+ }
+
+ // Run everything in background to avoid blocking UI during refresh/checks
+ go func() {
+ // 1. Refresh first to ensure DB connection is current
+ a.refreshPanels()
+
+ // 2. Check DB connection
+ migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext)
+ if !ok {
+ a.finishCommand()
+ a.g.Update(func(g *gocui.Gui) error {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError,
+ a.Tr.ErrorFailedAccessMigrationsPanel,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return nil
+ })
+ return
+ }
+
+ // Check if DB is connected
+ if !migrationsPanel.IsDBConnected() {
+ a.finishCommand()
+ a.g.Update(func(g *gocui.Gui) error {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBConnectionRequired,
+ a.Tr.ErrorNoDBConnectionDetected,
+ a.Tr.ErrorEnsureDBAccessible,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return nil
+ })
+ return
+ }
+
+ outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext)
+ if !ok {
+ a.finishCommand() // Clean up if panel not found
+ return
+ }
+
+ // Get current working directory
+ cwd, err := os.Getwd()
+ if err != nil {
+ a.finishCommand()
+ a.g.Update(func(g *gocui.Gui) error {
+ outputPanel.LogAction(a.Tr.LogActionMigrateDeployFailed, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeployError,
+ a.Tr.ErrorFailedGetWorkingDir,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return nil
+ })
+ return
+ }
+
+ // Log action start
+ a.g.Update(func(g *gocui.Gui) error {
+ outputPanel.LogAction(a.Tr.LogActionMigrateDeploy, a.Tr.LogMsgRunningMigrateDeploy)
+ return nil
+ })
+
+ // Create command builder
+ builder := commands.NewCommandBuilder(commands.NewPlatform())
+
+ // Build prisma migrate deploy command
+ deployCmd := builder.New("npx", "prisma", "migrate", "deploy").
+ WithWorkingDir(cwd).
+ StreamOutput().
+ OnStdout(func(line string) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.AppendOutput(" " + line)
+ }
+ return nil
+ })
+ }).
+ OnStderr(func(line string) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.AppendOutput(" " + line)
+ }
+ return nil
+ })
+ }).
+ OnComplete(func(exitCode int) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ a.finishCommand() // Finish command
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ if exitCode == 0 {
+ out.LogAction(a.Tr.LogActionMigrateDeployComplete, a.Tr.LogMsgMigrationsAppliedSuccess)
+ // Refresh all panels to show updated migration status
+ a.RefreshAll()
+ // Show success modal
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeploySuccess,
+ a.Tr.ModalMsgMigrationsAppliedSuccess,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
+ a.OpenModal(modal)
+ } else {
+ out.LogAction(a.Tr.LogActionMigrateDeployFailed, fmt.Sprintf(a.Tr.LogMsgMigrateDeployFailedCode, exitCode))
+ // Refresh even on failure - DB state may have changed
+ a.RefreshAll()
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeployFailed,
+ fmt.Sprintf(a.Tr.ModalMsgMigrateDeployFailedWithCode, exitCode),
+ a.Tr.ModalMsgCheckOutputPanel,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+ }
+ return nil
+ })
+ }).
+ OnError(func(err error) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ a.finishCommand() // Finish command
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.LogAction(a.Tr.LogActionMigrateDeployFailed, err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeployError,
+ a.Tr.ModalMsgFailedRunMigrateDeploy,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+ return nil
+ })
+ })
+
+ // Run async to avoid blocking UI (spinner will show automatically)
+ if err := deployCmd.RunAsync(); err != nil {
+ a.finishCommand() // Clean up if command fails to start
+ a.g.Update(func(g *gocui.Gui) error {
+ outputPanel.LogAction(a.Tr.LogActionMigrateDeployFailed, a.Tr.ModalMsgFailedStartMigrateDeploy+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDeployError,
+ a.Tr.ModalMsgFailedStartMigrateDeploy,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return nil
+ })
+ }
+ }()
+}
+
+// MigrateDev opens a list modal to choose migration type
+func (a *App) MigrateDev() {
+ items := []ListModalItem{
+ {
+ Label: a.Tr.ListItemSchemaDiffMigration,
+ Description: a.Tr.ListItemDescSchemaDiffMigration,
+ OnSelect: func() error {
+ a.CloseModal()
+ a.SchemaDiffMigration()
+ return nil
+ },
+ },
+ {
+ Label: a.Tr.ListItemManualMigration,
+ Description: a.Tr.ListItemDescManualMigration,
+ OnSelect: func() error {
+ a.CloseModal()
+ a.showManualMigrationInput()
+ return nil
+ },
+ },
+ }
+
+ modal := NewListModal(a.g, a.Tr, a.Tr.ModalTitleMigrateDev, items,
+ func() {
+ // Cancel - just close modal
+ a.CloseModal()
+ },
+ ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan})
+
+ a.OpenModal(modal)
+}
+
+// executeCreateMigration runs npx prisma migrate dev --name --create-only
+func (a *App) executeCreateMigration(migrationName string) {
+ // Try to start command - if another command is running, block
+ if !a.tryStartCommand("Create Migration") {
+ a.logCommandBlocked("Create Migration")
+ return
+ }
+
+ outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext)
+ if !ok {
+ a.finishCommand() // Clean up if panel not found
+ return
+ }
+
+ // Get current working directory
+ cwd, err := os.Getwd()
+ if err != nil {
+ a.finishCommand()
+ outputPanel.LogAction(a.Tr.LogActionMigrationError, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationError,
+ a.Tr.ErrorFailedGetWorkingDir,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Log action start
+ outputPanel.LogAction(a.Tr.LogActionMigrateDev, fmt.Sprintf(a.Tr.LogMsgCreatingMigration, migrationName))
+
+ // Create command builder
+ builder := commands.NewCommandBuilder(commands.NewPlatform())
+
+ // Build prisma migrate dev --create-only command
+ // Note: --create-only flag creates the migration without applying it to the database
+ createCmd := builder.New("npx", "prisma", "migrate", "dev", "--name", migrationName, "--create-only").
+ WithWorkingDir(cwd).
+ StreamOutput().
+ OnStdout(func(line string) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.AppendOutput(" " + line)
+ }
+ return nil
+ })
+ }).
+ OnStderr(func(line string) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.AppendOutput(" " + line)
+ }
+ return nil
+ })
+ }).
+ OnComplete(func(exitCode int) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ a.finishCommand() // Finish command
+ // Refresh all panels to show the new migration
+ a.RefreshAll()
+
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ if exitCode == 0 {
+ out.LogAction(a.Tr.LogActionMigrateComplete, a.Tr.LogMsgMigrationCreatedSuccess)
+ // Show success modal
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationCreated,
+ fmt.Sprintf(a.Tr.ModalMsgMigrationCreatedSuccess, migrationName),
+ a.Tr.ModalMsgMigrationCreatedDetail,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
+ a.OpenModal(modal)
+ } else {
+ out.LogAction(a.Tr.LogActionMigrateFailed, fmt.Sprintf(a.Tr.LogMsgMigrationCreationFailedCode, exitCode))
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationFailed,
+ fmt.Sprintf(a.Tr.ModalMsgMigrationFailedWithCode, exitCode),
+ a.Tr.ModalMsgCheckOutputPanel,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+ }
+ return nil
+ })
+ }).
+ OnError(func(err error) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ a.finishCommand() // Finish command
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.LogAction(a.Tr.LogActionMigrationError, err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationError,
+ a.Tr.ModalMsgFailedRunMigrateDeploy,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+ return nil
+ })
+ })
+
+ // Run async to avoid blocking UI (spinner will show automatically)
+ if err := createCmd.RunAsync(); err != nil {
+ a.finishCommand() // Clean up if command fails to start
+ outputPanel.LogAction(a.Tr.LogActionMigrationError, a.Tr.ModalMsgFailedStartMigrateDeploy+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationError,
+ a.Tr.ModalMsgFailedStartMigrateDeploy,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+}
+
+// SchemaDiffMigration performs schema diff-based migration with validation checks
+func (a *App) SchemaDiffMigration() {
+ // 1. Refresh first (with callback to ensure data is loaded before checking)
+ started := a.RefreshAll(func() {
+ // 2. Check DB connection
+ migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext)
+ if !ok {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError,
+ a.Tr.ErrorFailedAccessMigrationsPanel,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Check if DB is connected
+ if !migrationsPanel.IsDBConnected() {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBConnectionRequired,
+ a.Tr.ErrorNoDBConnectionDetected,
+ a.Tr.ErrorEnsureDBAccessible,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // 3. Check for DB-Only migrations
+ if len(migrationsPanel.GetCategory().DBOnly) > 0 {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDBOnlyMigrationsDetected,
+ a.Tr.ModalMsgCannotCreateWithDBOnly,
+ a.Tr.ModalMsgResolveDBOnlyFirst,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // 4. Check for Checksum Mismatch
+ for _, m := range migrationsPanel.GetCategory().Local {
+ if m.ChecksumMismatch {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleChecksumMismatchDetected,
+ a.Tr.ModalMsgCannotCreateWithMismatch,
+ fmt.Sprintf(a.Tr.ModalMsgMigrationModifiedLocally, m.Name),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+ }
+
+ // 5. Check for Pending migrations
+ if len(migrationsPanel.GetCategory().Pending) > 0 {
+ // Check if any pending migration is empty
+ for _, m := range migrationsPanel.GetCategory().Pending {
+ if m.IsEmpty {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleEmptyPendingDetected,
+ a.Tr.ModalMsgCannotCreateWithEmpty,
+ fmt.Sprintf(a.Tr.ModalMsgMigrationPendingEmpty, m.Name),
+ a.Tr.ModalMsgDeleteOrAddContent,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+ }
+
+ // Show confirmation modal for normal pending migrations
+ modal := NewConfirmModal(a.g, a.Tr, a.Tr.ModalTitlePendingMigrationsDetected,
+ a.Tr.ModalMsgPendingMigrationsWarning,
+ func() {
+ // Yes - proceed with migration name input
+ a.CloseModal()
+ a.showMigrationNameInput()
+ },
+ func() {
+ // No - cancel
+ a.CloseModal()
+ },
+ ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow})
+ a.OpenModal(modal)
+ return
+ }
+
+ // All checks passed - show migration name input
+ a.showMigrationNameInput()
+ })
+
+ if !started {
+ // If refresh failed to start (e.g., another command running), show error
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleOperationBlocked,
+ a.Tr.ModalMsgAnotherOperationRunning,
+ a.Tr.ModalMsgWaitComplete,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+}
+
+// createManualMigration creates a manual migration folder and file
+func (a *App) createManualMigration(migrationName string) {
+ // Get current working directory
+ cwd, err := os.Getwd()
+ if err != nil {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError,
+ a.Tr.ErrorFailedGetWorkingDir,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Generate timestamp (YYYYMMDDHHmmss format) in UTC to match Prisma CLI behavior
+ timestamp := time.Now().UTC().Format("20060102150405")
+ folderName := fmt.Sprintf("%s_%s", timestamp, migrationName)
+
+ // Migration folder path (prisma/migrations/{timestamp}_{name})
+ migrationsDir := fmt.Sprintf("%s/prisma/migrations", cwd)
+ migrationFolder := fmt.Sprintf("%s/%s", migrationsDir, folderName)
+
+ // Create migration folder
+ if err := os.MkdirAll(migrationFolder, 0755); err != nil {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError,
+ a.Tr.ModalMsgFailedCreateFolder,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Create migration.sql file with initial comment
+ migrationFile := fmt.Sprintf("%s/migration.sql", migrationFolder)
+ initialContent := "-- This migration was manually created via lazyprisma\n\n"
+
+ if err := os.WriteFile(migrationFile, []byte(initialContent), 0644); err != nil {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError,
+ a.Tr.ModalMsgFailedWriteMigrationFile,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Success - show result and refresh
+ a.RefreshAll()
+
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrationCreated,
+ fmt.Sprintf(a.Tr.ModalMsgManualMigrationCreated, folderName),
+ fmt.Sprintf(a.Tr.ModalMsgManualMigrationLocation, migrationFolder),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
+ a.OpenModal(modal)
+}
+
+// showMigrationNameInput shows input modal for migration name
+func (a *App) showMigrationNameInput() {
+ modal := NewInputModal(a.g, a.Tr, a.Tr.ModalTitleEnterMigrationName,
+ func(input string) {
+ // Replace spaces with underscores
+ migrationName := strings.ReplaceAll(strings.TrimSpace(input), " ", "_")
+
+ // Close input modal
+ a.CloseModal()
+
+ // Execute actual migration creation
+ a.executeCreateMigration(migrationName)
+ },
+ func() {
+ // Cancel - just close modal
+ a.CloseModal()
+ },
+ ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}).
+ WithSubtitle(a.Tr.ModalMsgSpacesReplaced).
+ WithRequired(true).
+ OnValidationFail(func(reason string) {
+ // Validation failed - show error
+ a.CloseModal()
+ errorModal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleValidationFailed,
+ reason,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(errorModal)
+ })
+
+ a.OpenModal(modal)
+}
+
+// showManualMigrationInput shows input modal for manual migration name
+func (a *App) showManualMigrationInput() {
+ modal := NewInputModal(a.g, a.Tr, a.Tr.ModalTitleEnterMigrationName,
+ func(input string) {
+ // Replace spaces with underscores
+ migrationName := strings.ReplaceAll(strings.TrimSpace(input), " ", "_")
+
+ // Close input modal
+ a.CloseModal()
+
+ // Create manual migration
+ a.createManualMigration(migrationName)
+ },
+ func() {
+ // Cancel - just close modal
+ a.CloseModal()
+ },
+ ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}).
+ WithSubtitle(a.Tr.ModalMsgSpacesReplaced).
+ WithRequired(true).
+ OnValidationFail(func(reason string) {
+ // Validation failed - show error
+ a.CloseModal()
+ errorModal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleValidationFailed,
+ reason,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(errorModal)
+ })
+
+ a.OpenModal(modal)
+}
+
+// MigrateResolve resolves a failed migration
+func (a *App) MigrateResolve() {
+ // Get migrations panel
+ migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext)
+ if !ok {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleError,
+ a.Tr.ErrorFailedAccessMigrationsPanel,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Get selected migration
+ selectedMigration := migrationsPanel.GetSelectedMigration()
+ if selectedMigration == nil {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleNoMigrationSelected,
+ a.Tr.ModalMsgSelectMigrationResolve,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Check if migration is failed (only In-Transaction migrations can be resolved)
+ if !selectedMigration.IsFailed {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotResolveMigration,
+ a.Tr.ModalMsgOnlyInTransactionResolve,
+ fmt.Sprintf(a.Tr.ModalMsgMigrationNotFailed, selectedMigration.Name),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Show ListModal with resolve options
+ migrationName := selectedMigration.Name
+
+ items := []ListModalItem{
+ {
+ Label: a.Tr.ListItemMarkApplied,
+ Description: a.Tr.ListItemDescMarkApplied,
+ OnSelect: func() error {
+ a.CloseModal()
+ a.executeResolve(migrationName, "applied")
+ return nil
+ },
+ },
+ {
+ Label: a.Tr.ListItemMarkRolledBack,
+ Description: a.Tr.ListItemDescMarkRolledBack,
+ OnSelect: func() error {
+ a.CloseModal()
+ a.executeResolve(migrationName, "rolled-back")
+ return nil
+ },
+ },
+ }
+
+ modal := NewListModal(a.g, a.Tr, fmt.Sprintf(a.Tr.ModalTitleResolveMigration, migrationName), items,
+ func() { a.CloseModal() },
+ ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan})
+
+ a.OpenModal(modal)
+}
+
+// executeResolve runs npx prisma migrate resolve with the specified action
+func (a *App) executeResolve(migrationName string, action string) {
+ // Try to start command - if another command is running, block
+ if !a.tryStartCommand("Migrate Resolve") {
+ a.logCommandBlocked("Migrate Resolve")
+ return
+ }
+
+ outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext)
+ if !ok {
+ a.finishCommand() // Clean up if panel not found
+ return
+ }
+
+ // Get current working directory
+ cwd, err := os.Getwd()
+ if err != nil {
+ a.finishCommand()
+ outputPanel.LogAction(a.Tr.LogActionMigrateResolveError, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveError,
+ a.Tr.ErrorFailedGetWorkingDir,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Log action start
+ actionLabel := a.Tr.ActionLabelApplied
+ if action == "rolled-back" {
+ actionLabel = a.Tr.ActionLabelRolledBack
+ }
+ outputPanel.LogAction(a.Tr.LogActionMigrateResolve, fmt.Sprintf(a.Tr.LogMsgMarkingMigration, actionLabel, migrationName))
+
+ // Create command builder
+ builder := commands.NewCommandBuilder(commands.NewPlatform())
+
+ // Build prisma migrate resolve command
+ resolveCmd := builder.New("npx", "prisma", "migrate", "resolve", "--"+action, migrationName).
+ WithWorkingDir(cwd).
+ StreamOutput().
+ OnStdout(func(line string) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.AppendOutput(" " + line)
+ }
+ return nil
+ })
+ }).
+ OnStderr(func(line string) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.AppendOutput(" " + line)
+ }
+ return nil
+ })
+ }).
+ OnComplete(func(exitCode int) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ a.finishCommand() // Finish command
+ // Refresh all panels to show updated migration status
+ a.RefreshAll()
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ if exitCode == 0 {
+ out.LogAction(a.Tr.LogActionMigrateResolveComplete, fmt.Sprintf(a.Tr.LogMsgMigrationMarked, actionLabel))
+ // Show success modal
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveSuccess,
+ fmt.Sprintf(a.Tr.ModalMsgMigrationMarkedSuccess, actionLabel),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
+ a.OpenModal(modal)
+ } else {
+ out.LogAction(a.Tr.LogActionMigrateResolveFailed, fmt.Sprintf(a.Tr.LogMsgMigrateResolveFailedCode, exitCode))
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveFailed,
+ fmt.Sprintf(a.Tr.ModalMsgMigrateResolveFailedWithCode, exitCode),
+ a.Tr.ModalMsgCheckOutputPanel,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+ }
+ return nil
+ })
+ }).
+ OnError(func(err error) {
+ // Update UI on main thread
+ a.g.Update(func(g *gocui.Gui) error {
+ a.finishCommand() // Finish command
+ if out, ok := a.panels[ViewOutputs].(*context.OutputContext); ok {
+ out.LogAction(a.Tr.LogActionMigrateResolveError, err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveError,
+ a.Tr.ModalMsgFailedRunMigrateResolve,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+ return nil
+ })
+ })
+
+ // Run async to avoid blocking UI (spinner will show automatically)
+ if err := resolveCmd.RunAsync(); err != nil {
+ a.finishCommand() // Clean up if command fails to start
+ outputPanel.LogAction(a.Tr.LogActionMigrateResolveError, a.Tr.ModalMsgFailedStartMigrateResolve+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleMigrateResolveError,
+ a.Tr.ModalMsgFailedStartMigrateResolve,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ }
+}
+
+// DeleteMigration deletes a pending migration
+func (a *App) DeleteMigration() {
+ // Get migrations panel
+ migrationsPanel, ok := a.panels[ViewMigrations].(*context.MigrationsContext)
+ if !ok {
+ return
+ }
+
+ // Get selected migration
+ selected := migrationsPanel.GetSelectedMigration()
+ if selected == nil {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleNoSelection,
+ a.Tr.ModalMsgSelectMigrationDelete,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Validate: Can only delete if it exists locally
+ if selected.Path == "" {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotDelete,
+ a.Tr.ModalMsgMigrationDBOnly,
+ a.Tr.ModalMsgCannotDeleteNoLocalFile,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Validate: Can only delete pending migrations (not applied to DB)
+ // Exception: If DB is not connected, we assume it's safe to delete local files (user responsibility)
+ if migrationsPanel.IsDBConnected() && selected.AppliedAt != nil {
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleCannotDelete,
+ a.Tr.ModalMsgMigrationAlreadyApplied,
+ a.Tr.ModalMsgDeleteLocalInconsistency,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Confirm deletion
+ modal := NewConfirmModal(a.g, a.Tr, a.Tr.ModalTitleDeleteMigration,
+ fmt.Sprintf(a.Tr.ModalMsgConfirmDeleteMigration, selected.Name),
+ func() {
+ a.CloseModal()
+ a.executeDeleteMigration(selected.Path, selected.Name)
+ },
+ func() {
+ a.CloseModal()
+ },
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+}
+
+// executeDeleteMigration performs the actual deletion
+func (a *App) executeDeleteMigration(path, name string) {
+ if err := os.RemoveAll(path); err != nil {
+ outputPanel, _ := a.panels[ViewOutputs].(*context.OutputContext)
+ if outputPanel != nil {
+ outputPanel.LogActionRed(a.Tr.ModalTitleDeleteError, fmt.Sprintf(a.Tr.LogMsgFailedDeleteMigration, err.Error()))
+ }
+
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDeleteError,
+ a.Tr.ModalMsgFailedDeleteFolder,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Success
+ outputPanel, _ := a.panels[ViewOutputs].(*context.OutputContext)
+ if outputPanel != nil {
+ outputPanel.LogAction(a.Tr.LogActionDeleted, fmt.Sprintf(a.Tr.LogMsgMigrationDeleted, name))
+ }
+
+ // Refresh to update list
+ a.RefreshAll()
+
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleDeleted,
+ a.Tr.ModalMsgMigrationDeletedSuccess,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
+ a.OpenModal(modal)
+}
diff --git a/pkg/app/output.go b/pkg/app/output.go
deleted file mode 100644
index 60484d3..0000000
--- a/pkg/app/output.go
+++ /dev/null
@@ -1,189 +0,0 @@
-package app
-
-import (
- "fmt"
- "time"
-
- "github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazycore/pkg/boxlayout"
-)
-
-type OutputPanel struct {
- BasePanel
- content string
- subtitle string // Custom subtitle
- originY int // Scroll position
- autoScrollToBottom bool // Auto-scroll to bottom on next draw
-}
-
-func NewOutputPanel(g *gocui.Gui) *OutputPanel {
- return &OutputPanel{
- BasePanel: NewBasePanel(ViewOutputs, g),
- content: "", // Start with empty output
- }
-}
-
-func (o *OutputPanel) Draw(dim boxlayout.Dimensions) error {
- v, err := o.g.SetView(o.id, dim.X0, dim.Y0, dim.X1, dim.Y1, 0)
- if err != nil && err.Error() != "unknown view" {
- return err
- }
-
- o.SetupView(v, "Output")
- o.v = v
- v.Subtitle = o.subtitle // Set subtitle
- v.Wrap = true // Enable word wrap
- fmt.Fprint(v, o.content)
-
- // Auto-scroll to bottom if flagged
- if o.autoScrollToBottom {
- // Calculate maxOrigin
- contentLines := len(v.ViewBufferLines())
- _, viewHeight := v.Size()
- innerHeight := viewHeight - 2 // Exclude frame
- maxOrigin := contentLines - innerHeight
- if maxOrigin < 0 {
- maxOrigin = 0
- }
- o.originY = maxOrigin
- o.autoScrollToBottom = false // Reset flag
- }
-
- // Adjust origin to ensure it's within valid bounds
- AdjustOrigin(v, &o.originY)
- v.SetOrigin(0, o.originY)
-
- return nil
-}
-
-func (o *OutputPanel) AppendOutput(text string) {
- o.content += text + "\n"
- // Flag to auto-scroll on next draw
- o.autoScrollToBottom = true
-}
-
-// LogAction logs an action with timestamp and optional details
-func (o *OutputPanel) LogAction(action string, details ...string) {
- // Get current timestamp
- timestamp := time.Now().Format("15:04:05")
-
- // Add separator if there's already content
- if o.content != "" {
- o.content += "\n"
- }
-
- // Format: [Timestamp] Action (in cyan bold)
- header := fmt.Sprintf("%s %s", Gray(timestamp), Stylize(action, Style{FgColor: ColorCyan, Bold: true}))
- o.content += header + "\n"
-
- // Add details with indentation
- for _, detail := range details {
- o.content += " " + detail + "\n"
- }
-
- // Flag to auto-scroll on next draw
- o.autoScrollToBottom = true
-}
-
-// SetSubtitle sets the custom subtitle for the panel
-func (o *OutputPanel) SetSubtitle(subtitle string) {
- o.subtitle = subtitle
-}
-
-// LogActionRed logs an action in red (for errors/warnings)
-func (o *OutputPanel) LogActionRed(action string, details ...string) {
- // Get current timestamp
- timestamp := time.Now().Format("15:04:05")
-
- // Add separator if there's already content
- if o.content != "" {
- o.content += "\n"
- }
-
- // Format: [Timestamp] Action in RED
- header := fmt.Sprintf("%s %s", Gray(timestamp),
- Stylize(action, Style{FgColor: ColorRed, Bold: true}))
- o.content += header + "\n"
-
- // Add details with indentation in red
- for _, detail := range details {
- o.content += " " + Red(detail) + "\n"
- }
-
- // Flag to auto-scroll on next draw
- o.autoScrollToBottom = true
-}
-
-// ScrollUp scrolls the output panel up
-func (o *OutputPanel) ScrollUp() {
- if o.originY > 0 {
- o.originY--
- }
-}
-
-// ScrollDown scrolls the output panel down
-func (o *OutputPanel) ScrollDown() {
- o.originY++
- // AdjustOrigin will be called in Draw() to ensure bounds
-}
-
-// ScrollUpByWheel scrolls the output panel up by 2 lines (mouse wheel)
-func (o *OutputPanel) ScrollUpByWheel() {
- if o.originY > 0 {
- o.originY -= 2
- if o.originY < 0 {
- o.originY = 0
- }
- }
-}
-
-// ScrollDownByWheel scrolls the output panel down by 2 lines (mouse wheel)
-func (o *OutputPanel) ScrollDownByWheel() {
- if o.v == nil {
- return
- }
-
- // Get actual content lines from the rendered view buffer
- contentLines := len(o.v.ViewBufferLines())
- _, viewHeight := o.v.Size()
- innerHeight := viewHeight - 2 // Exclude frame (top + bottom)
-
- // Calculate maxOrigin
- maxOrigin := contentLines - innerHeight
- if maxOrigin < 0 {
- maxOrigin = 0
- }
-
- // Only scroll if we haven't reached the bottom
- if o.originY < maxOrigin {
- o.originY += 2
- if o.originY > maxOrigin {
- o.originY = maxOrigin
- }
- }
-}
-
-// ScrollToTop scrolls to the top of the output panel
-func (o *OutputPanel) ScrollToTop() {
- o.originY = 0
-}
-
-// ScrollToBottom scrolls to the bottom of the output panel
-func (o *OutputPanel) ScrollToBottom() {
- if o.v == nil {
- return
- }
-
- // Get actual content lines from the rendered view buffer
- contentLines := len(o.v.ViewBufferLines())
- _, viewHeight := o.v.Size()
- innerHeight := viewHeight - 2 // Exclude frame (top + bottom)
-
- // Calculate maxOrigin
- maxOrigin := contentLines - innerHeight
- if maxOrigin < 0 {
- maxOrigin = 0
- }
-
- o.originY = maxOrigin
-}
diff --git a/pkg/app/panel.go b/pkg/app/panel.go
index 58a77aa..99b1f67 100644
--- a/pkg/app/panel.go
+++ b/pkg/app/panel.go
@@ -66,7 +66,7 @@ func (bp *BasePanel) OnBlur() {
}
}
-// SetupView는 공통 뷰 설정을 처리합니다
+// SetupView handles common view setup
func (bp *BasePanel) SetupView(v *gocui.View, title string) {
bp.v = v
v.Clear()
diff --git a/pkg/app/statusbar.go b/pkg/app/statusbar.go
deleted file mode 100644
index 13b11fe..0000000
--- a/pkg/app/statusbar.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package app
-
-import (
- "fmt"
-
- "github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazycore/pkg/boxlayout"
-)
-
-type StatusBar struct {
- BasePanel
- app *App // Reference to App for accessing command state
-}
-
-func NewStatusBar(g *gocui.Gui, app *App) *StatusBar {
- return &StatusBar{
- BasePanel: NewBasePanel(ViewStatusbar, g),
- app: app,
- }
-}
-
-func (s *StatusBar) Draw(dim boxlayout.Dimensions) error {
- // StatusBar has no frame, so adjust dimensions
- frameOffset := 1
- x0 := dim.X0 - frameOffset
- y0 := dim.Y0 - frameOffset
- x1 := dim.X1 + frameOffset
- y1 := dim.Y1 + frameOffset
-
- v, err := s.g.SetView(s.id, x0, y0, x1, y1, 0)
- if err != nil && err.Error() != "unknown view" {
- return err
- }
-
- s.v = v
- v.Clear()
- v.Frame = false
-
- // Build status bar content
- var leftContent string
- var visibleLen int
-
- // Show spinner if command is running
- if s.app.commandRunning.Load() {
- frameIndex := s.app.spinnerFrame.Load()
- spinner := string(spinnerFrames[frameIndex])
-
- // Get running task name
- taskName := ""
- if val := s.app.runningCommandName.Load(); val != nil {
- taskName = val.(string)
- }
-
- leftContent = fmt.Sprintf(" %s %s ", Cyan(spinner), Gray(taskName))
- visibleLen += 1 + 1 + 1 + len(taskName) + 1 // " " + spinner + " " + taskName + " "
- } else {
- leftContent = " " // Single space when not running
- visibleLen += 1
- }
-
- // Show Studio status if running
- if s.app.studioRunning {
- studioMsg := "[Studio: ON]"
- leftContent += fmt.Sprintf("%s ", Green(studioMsg))
- visibleLen += len(studioMsg) + 1
- }
-
- // Helper to format key binding: [k]ey -> [Cyan(k)]Gray(ey)
- // Returns styled string and its visible length
- appendKey := func(key, desc string) {
- // Style: [key]desc
- styled := fmt.Sprintf("[%s]%s", Cyan(key), Gray(desc))
- // Visible: [key]desc
- vLen := 1 + len(key) + 1 + len(desc)
-
- leftContent += styled + " "
- visibleLen += vLen + 1
- }
-
- appendKey("r", "efresh")
- appendKey("d", "ev")
- appendKey("D", "eploy")
- appendKey("g", "enerate")
- appendKey("s", "resolve")
- appendKey("S", "tudio")
- appendKey("c", "opy")
-
- // Right content (Metadata)
- // Style right content (e.g., in blue or default)
- styledRight := fmt.Sprintf("%s %s", Blue(s.app.config.Developer), Gray(s.app.config.Version))
- rightLen := len(s.app.config.Developer) + 1 + len(s.app.config.Version)
-
- // Calculate padding
- viewWidth, _ := v.Size()
- paddingLen := viewWidth - visibleLen - rightLen - 2 // -2 for extra safety buffer
-
- if paddingLen < 1 {
- paddingLen = 1
- }
-
- padding := ""
- for i := 0; i < paddingLen; i++ {
- padding += " "
- }
-
- fmt.Fprint(v, leftContent + padding + styledRight)
-
- return nil
-}
-
-// 상태바는 포커스를 받지 않음
-func (s *StatusBar) OnFocus() {}
-func (s *StatusBar) OnBlur() {}
diff --git a/pkg/app/studio_controller.go b/pkg/app/studio_controller.go
new file mode 100644
index 0000000..b93942a
--- /dev/null
+++ b/pkg/app/studio_controller.go
@@ -0,0 +1,120 @@
+package app
+
+import (
+ "os"
+ "time"
+
+ "github.com/dokadev/lazyprisma/pkg/commands"
+ "github.com/dokadev/lazyprisma/pkg/gui/context"
+ "github.com/jesseduffield/gocui"
+)
+
+// Studio toggles Prisma Studio
+func (a *App) Studio() {
+ outputPanel, ok := a.panels[ViewOutputs].(*context.OutputContext)
+ if !ok {
+ return
+ }
+
+ // Check if Studio is already running
+ if a.studioRunning {
+ // Stop Studio
+ if a.studioCmd != nil {
+ if err := a.studioCmd.Kill(); err != nil {
+ outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.ModalMsgFailedStopStudio+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioError,
+ a.Tr.ModalMsgFailedStopStudio,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+ a.studioCmd = nil
+ }
+ a.studioRunning = false
+ outputPanel.LogAction(a.Tr.LogActionStudioStopped, a.Tr.LogMsgStudioHasStopped)
+
+ // Clear subtitle
+ outputPanel.SetSubtitle("")
+
+ // Update UI
+ a.g.Update(func(g *gocui.Gui) error {
+ // Trigger redraw of status bar
+ return nil
+ })
+
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStopped,
+ a.Tr.ModalMsgStudioStopped,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Start Studio
+ // Try to start command - if another command is running, block
+ if !a.tryStartCommand("Start Studio") {
+ a.logCommandBlocked("Start Studio")
+ return
+ }
+
+ // Get current working directory
+ cwd, err := os.Getwd()
+ if err != nil {
+ a.finishCommand()
+ outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.ErrorFailedGetWorkingDir+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioError,
+ a.Tr.ErrorFailedGetWorkingDir,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Log action start
+ outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.LogMsgStartingStudio)
+
+ // Create command builder
+ builder := commands.NewCommandBuilder(commands.NewPlatform())
+
+ // Build prisma studio command
+ // Note: We don't use StreamOutput here because Studio is a long-running process
+ // and we want to capture the command object to kill it later
+ studioCmd := builder.New("npx", "prisma", "studio").
+ WithWorkingDir(cwd)
+
+ // Start async
+ if err := studioCmd.RunAsync(); err != nil {
+ a.finishCommand()
+ outputPanel.LogAction(a.Tr.LogActionStudio, a.Tr.ModalMsgFailedStartStudio+" "+err.Error())
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioError,
+ a.Tr.ModalMsgFailedStartStudio,
+ err.Error(),
+ ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
+ a.OpenModal(modal)
+ return
+ }
+
+ // Mark studio as running immediately to prevent double-start
+ a.studioRunning = true
+ a.studioCmd = studioCmd
+
+ // Wait a bit to ensure it started, then finish the "starting" command
+ // The process continues running in background
+ go func() {
+ time.Sleep(2 * time.Second)
+ a.g.Update(func(g *gocui.Gui) error {
+ a.finishCommand() // Finish "starting" command
+
+ outputPanel.LogAction(a.Tr.LogActionStudioStarted, a.Tr.LogMsgStudioListeningAt)
+ outputPanel.SetSubtitle(a.Tr.LogMsgStudioListeningAt)
+
+ // Show info modal
+ modal := NewMessageModal(a.g, a.Tr, a.Tr.ModalTitleStudioStarted,
+ a.Tr.ModalMsgStudioRunningAt,
+ a.Tr.ModalMsgPressStopStudio,
+ ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
+ a.OpenModal(modal)
+ return nil
+ })
+ }()
+}
diff --git a/pkg/app/test.go b/pkg/app/test.go
deleted file mode 100644
index be65f78..0000000
--- a/pkg/app/test.go
+++ /dev/null
@@ -1,256 +0,0 @@
-package app
-
-import (
- "fmt"
- "os"
-
- "github.com/dokadev/lazyprisma/pkg/commands"
- "github.com/dokadev/lazyprisma/pkg/prisma"
- "github.com/jesseduffield/gocui"
-)
-
-// TestModal opens a test modal (temporary for testing)
-func (a *App) TestModal() {
- // Get current working directory
- cwd, err := os.Getwd()
- if err != nil {
- modal := NewMessageModal(a.g, "Error",
- "Failed to get working directory:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Run validation
- result, err := prisma.Validate(cwd)
- if err != nil {
- modal := NewMessageModal(a.g, "Validation Error",
- "Failed to run validation:",
- err.Error(),
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- return
- }
-
- // Show result
- if result.Valid {
- // Validation passed
- modal := NewMessageModal(a.g, "Schema Validation Passed",
- "Your Prisma schema is valid!",
- ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan})
- a.OpenModal(modal)
- } else {
- // Validation failed - show errors
- lines := []string{"Schema validation failed with the following errors:"}
- if len(result.Errors) > 0 {
- for _, err := range result.Errors {
- styledErr := Stylize(err, Style{FgColor: ColorRed, Bold: true})
- lines = append(lines, styledErr)
- }
- } else {
- styledOutput := Stylize(result.Output, Style{FgColor: ColorRed, Bold: true})
- lines = append(lines, styledOutput)
- }
-
- modal := NewMessageModal(a.g, "Schema Validation Failed", lines...).
- WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(modal)
- }
-}
-
-// TestInputModal opens a test input modal (temporary for testing)
-func (a *App) TestInputModal() {
- modal := NewInputModal(a.g, "Enter migration name",
- func(input string) {
- // Close input modal
- a.CloseModal()
-
- // Show result in message modal
- resultModal := NewMessageModal(a.g, "Input Received",
- "You entered:",
- input,
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(resultModal)
- },
- func() {
- // Cancel - just close modal
- a.CloseModal()
- },
- ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan}).
- WithRequired(true).
- OnValidationFail(func(reason string) {
- // Close input modal and show error modal
- a.CloseModal()
-
- errorModal := NewMessageModal(a.g, "Validation Failed",
- reason,
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(errorModal)
- })
-
- a.OpenModal(modal)
-}
-
-// TestListModal opens a test list modal (temporary for testing)
-func (a *App) TestListModal() {
- items := []ListModalItem{
- {
- Label: "Create Migration",
- Description: "Create a new migration file.\n\nThis will:\n• Generate a new migration file in prisma/migrations\n• Include timestamp in the filename\n• Prompt for migration name",
- OnSelect: func() error {
- a.CloseModal()
- resultModal := NewMessageModal(a.g, "Action Selected",
- "You selected: Create Migration",
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(resultModal)
- return nil
- },
- },
- {
- Label: "Run Migrations",
- Description: "Apply pending migrations to the database.\n\nThis will:\n• Execute all pending migrations in order\n• Update _prisma_migrations table\n• May modify database schema",
- OnSelect: func() error {
- a.CloseModal()
- resultModal := NewMessageModal(a.g, "Action Selected",
- "You selected: Run Migrations",
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(resultModal)
- return nil
- },
- },
- {
- Label: "Reset Database",
- Description: "Reset the database to a clean state.\n\nWARNING: This will:\n• Drop all tables and data\n• Re-run all migrations from scratch\n• Cannot be undone",
- OnSelect: func() error {
- a.CloseModal()
- resultModal := NewMessageModal(a.g, "Action Selected",
- "You selected: Reset Database",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(resultModal)
- return nil
- },
- },
- {
- Label: "Validate Schema",
- Description: "Validate the Prisma schema file.\n\nThis will:\n• Check for syntax errors\n• Verify model relationships\n• Validate field types\n• Report any issues",
- OnSelect: func() error {
- a.CloseModal()
- resultModal := NewMessageModal(a.g, "Action Selected",
- "You selected: Validate Schema",
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(resultModal)
- return nil
- },
- },
- }
-
- modal := NewListModal(a.g, "Select Action", items,
- func() {
- // Cancel - just close modal
- a.CloseModal()
- },
- ).WithStyle(MessageModalStyle{TitleColor: ColorCyan, BorderColor: ColorCyan})
-
- a.OpenModal(modal)
-}
-
-// TestConfirmModal opens a test confirm modal (temporary for testing)
-func (a *App) TestConfirmModal() {
- modal := NewConfirmModal(a.g, "Confirm Action",
- "Are you sure you want to proceed with this action? This cannot be undone.",
- func() {
- // Yes callback - close confirm modal and show result
- a.CloseModal()
- resultModal := NewMessageModal(a.g, "Confirmed",
- "You clicked Yes!",
- ).WithStyle(MessageModalStyle{TitleColor: ColorGreen, BorderColor: ColorGreen})
- a.OpenModal(resultModal)
- },
- func() {
- // No callback - close confirm modal and show result
- a.CloseModal()
- resultModal := NewMessageModal(a.g, "Cancelled",
- "You clicked No!",
- ).WithStyle(MessageModalStyle{TitleColor: ColorRed, BorderColor: ColorRed})
- a.OpenModal(resultModal)
- },
- ).WithStyle(MessageModalStyle{TitleColor: ColorYellow, BorderColor: ColorYellow})
-
- a.OpenModal(modal)
-}
-
-// TestPing tests network connectivity by pinging google.com
-func (a *App) TestPing() {
- // Try to start command - if another command is running, block
- if !a.tryStartCommand("Network Test") {
- a.logCommandBlocked("Network Test")
- return
- }
-
- outputPanel, ok := a.panels[ViewOutputs].(*OutputPanel)
- if !ok {
- a.finishCommand() // Clean up if panel not found
- return
- }
-
- // Log action start
- outputPanel.LogAction("Network Test", "Pinging google.com...")
-
- // Create command builder
- builder := commands.NewCommandBuilder(commands.NewPlatform())
-
- // Build ping command (4 pings)
- pingCmd := builder.New("ping", "-c", "4", "google.com").
- StreamOutput().
- OnStdout(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" " + line)
- }
- return nil
- })
- }).
- OnStderr(func(line string) {
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.AppendOutput(" [ERROR] " + line)
- }
- return nil
- })
- }).
- OnComplete(func(exitCode int) {
- defer a.finishCommand() // Mark command as complete
-
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- if exitCode == 0 {
- out.LogAction("Network Test Complete", "Ping successful")
- } else {
- out.LogAction("Network Test Failed", fmt.Sprintf("Ping failed with exit code: %d", exitCode))
- }
- }
- return nil
- })
- }).
- OnError(func(err error) {
- defer a.finishCommand() // Mark command as complete even on error
-
- // Update UI on main thread
- a.g.Update(func(g *gocui.Gui) error {
- if out, ok := a.panels[ViewOutputs].(*OutputPanel); ok {
- out.LogAction("Network Test Error", err.Error())
- }
- return nil
- })
- })
-
- // Run async to avoid blocking UI
- if err := pingCmd.RunAsync(); err != nil {
- a.finishCommand() // Clean up if command fails to start
- outputPanel.LogAction("Network Test Error", "Failed to start ping: "+err.Error())
- }
-}
diff --git a/pkg/app/workspace.go b/pkg/app/workspace.go
deleted file mode 100644
index 0b5a7a2..0000000
--- a/pkg/app/workspace.go
+++ /dev/null
@@ -1,393 +0,0 @@
-package app
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/dokadev/lazyprisma/pkg/database"
- _ "github.com/dokadev/lazyprisma/pkg/database/drivers" // Register database drivers
- "github.com/dokadev/lazyprisma/pkg/git"
- "github.com/dokadev/lazyprisma/pkg/node"
- "github.com/dokadev/lazyprisma/pkg/prisma"
- "github.com/jesseduffield/gocui"
- "github.com/jesseduffield/lazycore/pkg/boxlayout"
-)
-
-type WorkspacePanel struct {
- BasePanel
- nodeVersion string
- prismaVersion string
- prismaGlobal bool
- gitRepoName string // Git repository name
- gitBranch string // Git branch name
- isGitRepo bool // True if current directory is a git repository
- schemaModified bool // True if schema.prisma has git changes
- unmaskedURL string
- maskedURL string
- showMasked bool
- dbProvider string
- dbConnected bool
- dbError string
- envVarName string // Environment variable name (e.g., "DATABASE_URL")
- isHardcoded bool // True if URL is hardcoded in schema/config
- originY int // Scroll position
-}
-
-func NewWorkspacePanel(g *gocui.Gui) *WorkspacePanel {
- wp := &WorkspacePanel{
- BasePanel: NewBasePanel(ViewWorkspace, g),
- showMasked: true, // Default to masked
- }
- wp.loadVersionInfo()
- return wp
-}
-
-func (w *WorkspacePanel) loadVersionInfo() {
- cwd, _ := os.Getwd()
-
- // Node version
- if nodeVer, err := node.GetVersion(); err == nil {
- w.nodeVersion = nodeVer.Version
- } else {
- w.nodeVersion = "Not found"
- }
-
- // Prisma version
- if prismaVer, err := prisma.GetVersion(cwd); err == nil {
- w.prismaVersion = prismaVer.Version
- w.prismaGlobal = prismaVer.IsGlobal
- } else {
- w.prismaVersion = "Not found"
- w.prismaGlobal = false
- }
-
- // Git info
- gitInfo := git.GetGitInfo(cwd)
- w.isGitRepo = gitInfo.IsRepository
- w.gitRepoName = gitInfo.RepositoryName
- w.gitBranch = gitInfo.BranchName
-
- // Check schema.prisma modification status (only if git repo)
- if w.isGitRepo {
- schemaPath := filepath.Join(cwd, prisma.SchemaDirName, prisma.SchemaFileName)
- w.schemaModified = git.IsFileModified(cwd, schemaPath)
- } else {
- w.schemaModified = false
- }
-
- // Load database info
- w.loadDatabaseInfo()
-}
-
-func (w *WorkspacePanel) buildDatabaseLines() []string {
- var lines []string
-
- // Display provider with status on the same line
- providerName := database.GetProviderDisplayName(w.dbProvider)
- if providerName == "Unknown" {
- providerName = Stylize(providerName, Style{FgColor: ColorYellow, Bold: true})
- } else {
- providerName = Stylize(providerName, Style{FgColor: ColorYellow, Bold: true})
- }
-
- // Build provider line with status
- var providerLine string
- if w.dbConnected {
- statusStyled := Stylize("✓ Connected", Style{FgColor: ColorGreen, Bold: true})
- providerLine = fmt.Sprintf("Provider: %s %s", providerName, statusStyled)
- } else if w.dbError != "" {
- if w.isConfigurationError() {
- statusStyled := Stylize("✗ Not configured", Style{FgColor: ColorRed, Bold: true})
- providerLine = fmt.Sprintf("Provider: %s %s", providerName, statusStyled)
- } else {
- statusStyled := Stylize("✗ Disconnected", Style{FgColor: ColorRed, Bold: true})
- providerLine = fmt.Sprintf("Provider: %s %s", providerName, statusStyled)
- }
- } else {
- statusStyled := Stylize("✗ Disconnected", Style{FgColor: ColorRed, Bold: true})
- providerLine = fmt.Sprintf("Provider: %s %s", providerName, statusStyled)
- }
- lines = append(lines, providerLine)
-
- // Display URL (always show if available)
- if w.unmaskedURL != "" {
- displayURL := w.maskedURL
- if !w.showMasked {
- displayURL = w.unmaskedURL
- }
-
- // Add hardcoded warning if applicable
- if w.isHardcoded {
- lines = append(lines, fmt.Sprintf("%s %s", displayURL, Red("(Hard coded)")))
- } else {
- lines = append(lines, displayURL)
- }
- } else if w.dbError != "" && w.isConfigurationError() {
- // Only show error in URL field if it's a configuration issue
- // Apply styling: bold+red env var name, red "not configured"
- if w.envVarName != "" && strings.Contains(w.dbError, " not configured") {
- styledError := Stylize(w.envVarName, Style{FgColor: ColorRed, Bold: true}) + Red(" not configured")
- lines = append(lines, styledError)
- } else {
- lines = append(lines, Red(w.dbError))
- }
- } else {
- lines = append(lines, "Not set")
- }
-
- // Show detailed error message if disconnected (not configuration error)
- if !w.dbConnected && w.dbError != "" && !w.isConfigurationError() {
- lines = append(lines, Red(fmt.Sprintf("Error: %s", w.dbError)))
- }
-
- return lines
-}
-
-// isConfigurationError checks if the error is a configuration issue
-func (w *WorkspacePanel) isConfigurationError() bool {
- if w.dbError == "" {
- return false
- }
-
- configErrors := []string{
- "not found",
- "not configured",
- "not set",
- "incomplete",
- "no database_url",
- }
-
- errLower := strings.ToLower(w.dbError)
- for _, substr := range configErrors {
- if strings.Contains(errLower, substr) {
- return true
- }
- }
- return false
-}
-
-func (w *WorkspacePanel) loadDatabaseInfo() {
- // Reset fields
- w.dbProvider = ""
- w.unmaskedURL = ""
- w.maskedURL = ""
- w.dbConnected = false
- w.dbError = ""
- w.envVarName = ""
- w.isHardcoded = false
-
- cwd, err := os.Getwd()
- if err != nil {
- w.dbError = "Error getting working directory"
- return
- }
-
- // Get datasource from schema
- ds, err := prisma.GetDatasource(cwd)
- if err != nil {
- // Try to extract provider only (even if URL resolution fails)
- if provider, err2 := prisma.GetProvider(cwd); err2 == nil {
- w.dbProvider = provider
- }
-
- // Try to extract env var name (even if resolution fails)
- if envVar, err2 := prisma.GetEnvVarName(cwd); err2 == nil {
- w.envVarName = envVar
- }
-
- // Categorize error message for better user understanding
- errMsg := err.Error()
- if strings.Contains(errMsg, "not found") {
- w.dbError = "Schema file not found"
- } else if strings.Contains(errMsg, "incomplete") {
- // Store plain text, styling will be applied in buildDatabaseLines()
- if w.envVarName != "" {
- w.dbError = w.envVarName + " not configured"
- } else {
- w.dbError = "DATABASE_URL not configured"
- }
- } else {
- w.dbError = errMsg
- }
- return
- }
-
- // Store provider, URLs, and metadata
- w.dbProvider = ds.Provider
- w.unmaskedURL = ds.URL
- w.maskedURL = prisma.MaskPassword(ds.URL)
- w.envVarName = ds.EnvVarName
- w.isHardcoded = ds.IsHardcoded
-
- // Try to connect to database
- if ds.URL == "" {
- if w.envVarName != "" {
- w.dbError = w.envVarName + " not configured"
- } else {
- w.dbError = "No DATABASE_URL"
- }
- return
- }
-
- // Attempt connection
- client, err := database.NewClientFromDSN(ds.Provider, ds.URL)
- if err != nil {
- w.dbError = err.Error()
- return
- }
- defer client.Close()
-
- // Test connection with ping
- if err := client.Ping(); err != nil {
- w.dbError = err.Error()
- return
- }
-
- // Connection successful
- w.dbConnected = true
-}
-
-func (w *WorkspacePanel) Draw(dim boxlayout.Dimensions) error {
- v, err := w.g.SetView(w.id, dim.X0, dim.Y0, dim.X1, dim.Y1, 0)
- if err != nil && err.Error() != "unknown view" {
- return err
- }
-
- w.SetupView(v, "Workspace")
- w.v = v
- v.Wrap = true // Enable word wrap
-
- // Build content from fields
- var lines []string
-
- // Node and Prisma version on one line
- nodeVersionStyled := Stylize(w.nodeVersion, Style{FgColor: ColorYellow, Bold: true})
- prismaVersionStyled := Stylize(w.prismaVersion, Style{FgColor: ColorYellow, Bold: true})
- versionLine := fmt.Sprintf("Node: %s | Prisma: %s", nodeVersionStyled, prismaVersionStyled)
- if w.prismaGlobal {
- versionLine += " " + Orange("(Global)")
- }
- lines = append(lines, versionLine)
-
- // Git info
- lines = append(lines, "")
- if w.isGitRepo {
- // Git line with optional schema modified indicator
- gitLine := fmt.Sprintf("Git: %s", w.gitRepoName)
- if w.schemaModified {
- gitLine += " " + Orange("(schema modified)")
- }
- lines = append(lines, gitLine)
-
- // Branch on separate line
- branchStyled := Stylize(w.gitBranch, Style{FgColor: ColorYellow, Bold: true})
- lines = append(lines, fmt.Sprintf("(%s)", branchStyled))
- } else {
- lines = append(lines, "Git: Not a git repository")
- }
-
- lines = append(lines, "")
- lines = append(lines, w.buildDatabaseLines()...)
-
- content := ""
- for _, line := range lines {
- content += line + "\n"
- }
-
- fmt.Fprint(v, content)
-
- // Adjust origin to ensure it's within valid bounds
- AdjustOrigin(v, &w.originY)
- v.SetOrigin(0, w.originY)
-
- return nil
-}
-
-// ScrollUp scrolls the workspace panel up
-func (w *WorkspacePanel) ScrollUp() {
- if w.originY > 0 {
- w.originY--
- }
-}
-
-// ScrollDown scrolls the workspace panel down
-func (w *WorkspacePanel) ScrollDown() {
- w.originY++
- // AdjustOrigin will be called in Draw() to ensure bounds
-}
-
-// ScrollUpByWheel scrolls the workspace panel up by 2 lines (mouse wheel)
-func (w *WorkspacePanel) ScrollUpByWheel() {
- if w.originY > 0 {
- w.originY -= 2
- if w.originY < 0 {
- w.originY = 0
- }
- }
-}
-
-// ScrollDownByWheel scrolls the workspace panel down by 2 lines (mouse wheel)
-func (w *WorkspacePanel) ScrollDownByWheel() {
- if w.v == nil {
- return
- }
-
- // Get actual content lines from the rendered view buffer
- contentLines := len(w.v.ViewBufferLines())
- _, viewHeight := w.v.Size()
- innerHeight := viewHeight - 2 // Exclude frame (top + bottom)
-
- // Calculate maxOrigin
- maxOrigin := contentLines - innerHeight
- if maxOrigin < 0 {
- maxOrigin = 0
- }
-
- // Only scroll if we haven't reached the bottom
- if w.originY < maxOrigin {
- w.originY += 2
- if w.originY > maxOrigin {
- w.originY = maxOrigin
- }
- }
-}
-
-// ScrollToTop scrolls to the top of the workspace panel
-func (w *WorkspacePanel) ScrollToTop() {
- w.originY = 0
-}
-
-// ScrollToBottom scrolls to the bottom of the workspace panel
-func (w *WorkspacePanel) ScrollToBottom() {
- if w.v == nil {
- return
- }
-
- // Get actual content lines from the rendered view buffer
- contentLines := len(w.v.ViewBufferLines())
- _, viewHeight := w.v.Size()
- innerHeight := viewHeight - 2 // Exclude frame (top + bottom)
-
- // Calculate maxOrigin
- maxOrigin := contentLines - innerHeight
- if maxOrigin < 0 {
- maxOrigin = 0
- }
-
- w.originY = maxOrigin
-}
-
-// Refresh reloads all workspace information
-func (w *WorkspacePanel) Refresh() {
- // Save current scroll position
- currentOriginY := w.originY
-
- // Reload information
- w.loadVersionInfo()
- w.loadDatabaseInfo()
-
- // Restore scroll position (will be adjusted by AdjustOrigin in Draw if needed)
- w.originY = currentOriginY
-}
diff --git a/pkg/commands/test.go b/pkg/commands/test.go
deleted file mode 100644
index d9c5934..0000000
--- a/pkg/commands/test.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package commands
-
-import (
- "context"
- "fmt"
- "time"
-)
-
-// RunTestSuite executes all command system tests
-func RunTestSuite() {
- fmt.Println("=== LazyPrisma Command Executor Test ===\n")
-
- // Create a context with cancellation support
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- // Create command builder
- builder := NewCommandBuilder(NewPlatform())
-
- TestGitStatus(ctx, builder)
- TestEchoCommand(builder)
- TestGitLogWithStreamHandler(builder)
- TestPingStreaming(ctx, builder)
-
- fmt.Println("=== All Tests Completed ===")
-}
-
-// TestGitStatus tests git status with streaming output
-func TestGitStatus(ctx context.Context, builder *CommandBuilder) {
- fmt.Println("Test 1: Git Status (Streaming)")
- fmt.Println("--------------------------------")
-
- cmd := builder.NewWithContext(ctx, "git", "status", "--short").
- StreamOutput().
- OnStdout(func(line string) {
- fmt.Printf("[STDOUT] %s\n", line)
- }).
- OnStderr(func(line string) {
- fmt.Printf("[STDERR] %s\n", line)
- }).
- OnComplete(func(exitCode int) {
- fmt.Printf("\n✓ Command completed with exit code: %d\n\n", exitCode)
- }).
- OnError(func(err error) {
- fmt.Printf("\n✗ Command error: %v\n\n", err)
- })
-
- if err := cmd.RunAsync(); err != nil {
- fmt.Printf("Failed to start command: %v\n", err)
- return
- }
-
- time.Sleep(2 * time.Second)
-}
-
-// TestEchoCommand tests echo with captured output
-func TestEchoCommand(builder *CommandBuilder) {
- fmt.Println("Test 2: Echo Command (Captured Output)")
- fmt.Println("---------------------------------------")
-
- echoCmd := builder.New("echo", "Hello from LazyPrisma!")
- result, err := echoCmd.RunWithOutput()
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- } else {
- fmt.Printf("Output: %s", result.Stdout)
- fmt.Printf("Exit Code: %d\n", result.ExitCode)
- fmt.Printf("Duration: %v\n\n", result.Duration)
- }
-}
-
-// TestGitLogWithStreamHandler tests git log with StreamHandler
-func TestGitLogWithStreamHandler(builder *CommandBuilder) {
- fmt.Println("Test 3: Git Log with StreamHandler")
- fmt.Println("-----------------------------------")
-
- logHandler := NewStreamHandler(func(stdout, stderr []string) {
- fmt.Printf("[Handler Update] %d stdout lines, %d stderr lines\n",
- len(stdout), len(stderr))
- })
-
- gitLogCmd := builder.New("git", "log", "--oneline", "-n", "5").
- StreamOutput().
- OnStdout(logHandler.HandleStdout).
- OnStderr(logHandler.HandleStderr).
- OnComplete(func(exitCode int) {
- stdout, stderr := logHandler.GetOutput()
- fmt.Printf("\nFinal buffered output:\n")
- for _, line := range stdout {
- fmt.Printf(" %s\n", line)
- }
- if len(stderr) > 0 {
- fmt.Printf("\nErrors:\n")
- for _, line := range stderr {
- fmt.Printf(" %s\n", line)
- }
- }
- fmt.Printf("\n✓ Git log completed (exit code: %d)\n\n", exitCode)
- })
-
- if err := gitLogCmd.RunAsync(); err != nil {
- fmt.Printf("Failed to start git log: %v\n", err)
- return
- }
-
- time.Sleep(2 * time.Second)
-}
-
-// TestPingStreaming tests ping with real-time streaming
-func TestPingStreaming(ctx context.Context, builder *CommandBuilder) {
- fmt.Println("Test 4: Ping google.com (Streaming)")
- fmt.Println("------------------------------------")
-
- pingCmd := builder.NewWithContext(ctx, "ping", "-c", "4", "google.com").
- StreamOutput().
- OnStdout(func(line string) {
- fmt.Printf("[PING] %s\n", line)
- }).
- OnStderr(func(line string) {
- fmt.Printf("[PING ERR] %s\n", line)
- }).
- OnComplete(func(exitCode int) {
- fmt.Printf("\n✓ Ping completed with exit code: %d\n\n", exitCode)
- }).
- OnError(func(err error) {
- fmt.Printf("\n✗ Ping error: %v\n\n", err)
- })
-
- if err := pingCmd.RunAsync(); err != nil {
- fmt.Printf("Failed to start ping: %v\n", err)
- return
- }
-
- time.Sleep(5 * time.Second)
-}
diff --git a/pkg/common/common.go b/pkg/common/common.go
new file mode 100644
index 0000000..47800cd
--- /dev/null
+++ b/pkg/common/common.go
@@ -0,0 +1,19 @@
+package common
+
+import (
+ "github.com/dokadev/lazyprisma/pkg/i18n"
+)
+
+// Common provides shared dependencies used across the application.
+// All components that need access to translations or configuration
+// should receive a *Common reference.
+type Common struct {
+ Tr *i18n.TranslationSet
+}
+
+// NewCommon creates a new Common instance with the given TranslationSet.
+func NewCommon(tr *i18n.TranslationSet) *Common {
+ return &Common{
+ Tr: tr,
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 2c52e73..7b82a50 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -15,7 +15,8 @@ const (
// Config holds application configuration
type Config struct {
- Scan ScanConfig `yaml:"scan"`
+ Scan ScanConfig `yaml:"scan"`
+ Language string `yaml:"language"`
}
// ScanConfig holds project scanning settings
@@ -31,6 +32,7 @@ func Default() *Config {
MaxDepth: 10,
ExcludeDirs: []string{}, // Additional excludes (defaults are in prisma.DefaultExcludeDirs)
},
+ Language: "auto",
}
}
@@ -127,6 +129,9 @@ scan:
excludeDirs:
# - /full/path/to/exclude
# - dirname-to-exclude
+
+# Language setting ("auto" for system detection, or a language code like "en", "de")
+language: auto
`
return os.WriteFile(path, []byte(defaultConfig), 0644)
}
diff --git a/pkg/database/test.go b/pkg/database/test.go
deleted file mode 100644
index 774e45d..0000000
--- a/pkg/database/test.go
+++ /dev/null
@@ -1,246 +0,0 @@
-package database
-
-import (
- "fmt"
- "time"
-)
-
-// RunTestSuite runs database module tests
-func RunTestSuite() {
- fmt.Println("=== LazyPrisma Database Module Test ===\n")
-
- TestRegistryList()
- TestConfigBuilders()
- TestClientCreation()
-
- // Real DB connection tests
- TestMySQLPrismaMigrations()
- TestPostgresPrismaMigrations()
-
- fmt.Println("=== Database Tests Completed ===")
-}
-
-// TestMySQLPrismaMigrations connects to MySQL and fetches Prisma migrations
-func TestMySQLPrismaMigrations() {
- fmt.Println("Test 4: MySQL Prisma Migrations")
- fmt.Println("--------------------------------")
-
- client, err := NewClient("mysql")
- if err != nil {
- fmt.Printf("✗ Failed to create client: %v\n", err)
- return
- }
-
- cfg := NewConfig().
- WithHost("localhost").
- WithPort(3308).
- WithUser("root").
- WithPassword("1234").
- WithDatabase("linkareer_local_dev_db")
-
- fmt.Printf("Connecting to: mysql://%s:****@%s:%d/%s\n", cfg.User, cfg.Host, cfg.Port, cfg.Database)
-
- if err := client.Connect(cfg); err != nil {
- fmt.Printf("✗ Failed to connect: %v\n", err)
- return
- }
- defer client.Close()
-
- fmt.Println("✓ Connected to MySQL\n")
-
- // Query Prisma migrations table
- rows, err := client.Query(`
- SELECT
- id,
- migration_name,
- started_at,
- finished_at,
- applied_steps_count
- FROM _prisma_migrations
- ORDER BY started_at DESC
- LIMIT 10
- `)
- if err != nil {
- fmt.Printf("✗ Failed to query migrations: %v\n", err)
- return
- }
- defer rows.Close()
-
- fmt.Println("\nPrisma Migrations (latest 10):")
-
- count := 0
- for rows.Next() {
- var id, name string
- var startedAt time.Time
- var finishedAt *time.Time
- var steps int
-
- if err := rows.Scan(&id, &name, &startedAt, &finishedAt, &steps); err != nil {
- fmt.Printf("✗ Scan error: %v\n", err)
- continue
- }
-
- fmt.Printf("[%d] %s\n", count+1, name)
- fmt.Printf(" ID: %s\n", id[:8])
- fmt.Printf(" Started: %s\n", startedAt.Format("2006-01-02 15:04:05"))
- if finishedAt != nil {
- fmt.Printf(" Finished: %s\n", finishedAt.Format("2006-01-02 15:04:05"))
- } else {
- fmt.Printf(" Finished: (failed)\n")
- }
- fmt.Printf(" Steps: %d\n\n", steps)
- count++
- }
-
- if count == 0 {
- fmt.Println("No migrations found.")
- } else {
- fmt.Printf("✓ Total: %d migration(s)\n", count)
- }
- fmt.Println()
-}
-
-// TestPostgresPrismaMigrations connects to PostgreSQL and fetches Prisma migrations
-func TestPostgresPrismaMigrations() {
- fmt.Println("Test 5: PostgreSQL Prisma Migrations")
- fmt.Println("-------------------------------------")
-
- client, err := NewClient("postgres")
- if err != nil {
- fmt.Printf("✗ Failed to create client: %v\n", err)
- return
- }
-
- cfg := NewConfig().
- WithHost("localhost").
- WithPort(6432).
- WithUser("linkareer").
- WithPassword("1234").
- WithDatabase("linkareer_chat_local_db")
-
- fmt.Printf("Connecting to: postgresql://%s:****@%s:%d/%s\n", cfg.User, cfg.Host, cfg.Port, cfg.Database)
-
- if err := client.Connect(cfg); err != nil {
- fmt.Printf("✗ Failed to connect: %v\n\n", err)
- return
- }
- defer client.Close()
-
- fmt.Println("✓ Connected to PostgreSQL")
-
- // Query Prisma migrations table
- rows, err := client.Query(`
- SELECT
- id,
- migration_name,
- started_at,
- finished_at,
- applied_steps_count
- FROM _prisma_migrations
- ORDER BY started_at DESC
- LIMIT 10
- `)
- if err != nil {
- fmt.Printf("✗ Failed to query migrations: %v\n\n", err)
- return
- }
- defer rows.Close()
-
- fmt.Println("\nPrisma Migrations (latest 10):")
-
- count := 0
- for rows.Next() {
- var id, name string
- var startedAt time.Time
- var finishedAt *time.Time
- var steps int
-
- if err := rows.Scan(&id, &name, &startedAt, &finishedAt, &steps); err != nil {
- fmt.Printf("✗ Scan error: %v\n", err)
- continue
- }
-
- fmt.Printf("[%d] %s\n", count+1, name)
- fmt.Printf(" ID: %s\n", id[:8])
- fmt.Printf(" Started: %s\n", startedAt.Format("2006-01-02 15:04:05"))
- if finishedAt != nil {
- fmt.Printf(" Finished: %s\n", finishedAt.Format("2006-01-02 15:04:05"))
- } else {
- fmt.Printf(" Finished: (failed)\n")
- }
- fmt.Printf(" Steps: %d\n\n", steps)
- count++
- }
-
- if count == 0 {
- fmt.Println("No migrations found.")
- } else {
- fmt.Printf("✓ Total: %d migration(s)\n", count)
- }
- fmt.Println()
-}
-
-// TestRegistryList tests driver registration
-func TestRegistryList() {
- fmt.Println("Test 1: Registry - List Registered Drivers")
- fmt.Println("-------------------------------------------")
-
- drivers := List()
- fmt.Printf("Registered drivers: %v\n", drivers)
-
- for _, name := range drivers {
- fmt.Printf(" - %s: Has=%v\n", name, Has(name))
- }
- fmt.Println()
-}
-
-// TestConfigBuilders tests config DSN builders
-func TestConfigBuilders() {
- fmt.Println("Test 2: Config - DSN Builders")
- fmt.Println("-----------------------------")
-
- cfg := NewConfig().
- WithHost("localhost").
- WithPort(5432).
- WithUser("admin").
- WithPassword("secret").
- WithDatabase("testdb").
- WithSSLMode("disable")
-
- fmt.Printf("PostgreSQL DSN:\n %s\n\n", cfg.PostgresDSN())
-
- cfg.WithPort(3306)
- fmt.Printf("MySQL DSN:\n %s\n\n", cfg.MySQLDSN())
-}
-
-// TestClientCreation tests client creation without actual connection
-func TestClientCreation() {
- fmt.Println("Test 3: Client - Creation (no connection)")
- fmt.Println("------------------------------------------")
-
- // Test postgres client
- pgClient, err := NewClient("postgres")
- if err != nil {
- fmt.Printf("✗ Failed to create postgres client: %v\n", err)
- } else {
- fmt.Printf("✓ Created postgres client (driver: %s)\n", pgClient.DriverName())
- }
-
- // Test mysql client
- mysqlClient, err := NewClient("mysql")
- if err != nil {
- fmt.Printf("✗ Failed to create mysql client: %v\n", err)
- } else {
- fmt.Printf("✓ Created mysql client (driver: %s)\n", mysqlClient.DriverName())
- }
-
- // Test unknown driver
- _, err = NewClient("unknown")
- if err != nil {
- fmt.Printf("✓ Correctly rejected unknown driver: %v\n", err)
- } else {
- fmt.Printf("✗ Should have rejected unknown driver\n")
- }
-
- fmt.Println()
-}
diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go
new file mode 100644
index 0000000..a3708aa
--- /dev/null
+++ b/pkg/gui/context/base_context.go
@@ -0,0 +1,105 @@
+package context
+
+import (
+ "github.com/dokadev/lazyprisma/pkg/gui/types"
+ "github.com/jesseduffield/gocui"
+)
+
+type BaseContext struct {
+ key types.ContextKey
+ kind types.ContextKind
+ viewName string
+ view *gocui.View
+ focusable bool
+ title string
+
+ // Lifecycle hooks (multiple can attach)
+ onFocusFns []func(types.OnFocusOpts)
+ onFocusLostFns []func(types.OnFocusLostOpts)
+
+ // Keybinding attachment
+ keybindingsFns []types.KeybindingsFn
+}
+
+var _ types.IBaseContext = &BaseContext{}
+
+type BaseContextOpts struct {
+ Key types.ContextKey
+ Kind types.ContextKind
+ ViewName string
+ View *gocui.View
+ Focusable bool
+ Title string
+}
+
+func NewBaseContext(opts BaseContextOpts) *BaseContext {
+ return &BaseContext{
+ key: opts.Key,
+ kind: opts.Kind,
+ viewName: opts.ViewName,
+ view: opts.View,
+ focusable: opts.Focusable,
+ title: opts.Title,
+ }
+}
+
+func (self *BaseContext) GetKey() types.ContextKey {
+ return self.key
+}
+
+func (self *BaseContext) GetKind() types.ContextKind {
+ return self.kind
+}
+
+func (self *BaseContext) GetViewName() string {
+ if self.view != nil {
+ return self.view.Name()
+ }
+ return self.viewName
+}
+
+func (self *BaseContext) GetView() *gocui.View {
+ return self.view
+}
+
+func (self *BaseContext) SetView(v *gocui.View) {
+ self.view = v
+}
+
+func (self *BaseContext) IsFocusable() bool {
+ return self.focusable
+}
+
+func (self *BaseContext) Title() string {
+ return self.title
+}
+
+// AddKeybindingsFn registers a function that provides keybindings for this context.
+// Controllers call this to attach their bindings.
+func (self *BaseContext) AddKeybindingsFn(fn types.KeybindingsFn) {
+ self.keybindingsFns = append(self.keybindingsFns, fn)
+}
+
+// GetKeybindings collects all registered keybindings.
+// Later-registered functions take precedence (appended in reverse order).
+func (self *BaseContext) GetKeybindings() []*types.Binding {
+ bindings := []*types.Binding{}
+ for i := range self.keybindingsFns {
+ bindings = append(bindings, self.keybindingsFns[len(self.keybindingsFns)-1-i]()...)
+ }
+ return bindings
+}
+
+// AddOnFocusFn registers a lifecycle hook called when this context gains focus.
+func (self *BaseContext) AddOnFocusFn(fn func(types.OnFocusOpts)) {
+ if fn != nil {
+ self.onFocusFns = append(self.onFocusFns, fn)
+ }
+}
+
+// AddOnFocusLostFn registers a lifecycle hook called when this context loses focus.
+func (self *BaseContext) AddOnFocusLostFn(fn func(types.OnFocusLostOpts)) {
+ if fn != nil {
+ self.onFocusLostFns = append(self.onFocusLostFns, fn)
+ }
+}
diff --git a/pkg/gui/context/details_context.go b/pkg/gui/context/details_context.go
new file mode 100644
index 0000000..51b39d5
--- /dev/null
+++ b/pkg/gui/context/details_context.go
@@ -0,0 +1,774 @@
+package context
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/alecthomas/chroma/v2/formatters"
+ "github.com/alecthomas/chroma/v2/lexers"
+ "github.com/alecthomas/chroma/v2/styles"
+ "github.com/dokadev/lazyprisma/pkg/gui/style"
+ "github.com/dokadev/lazyprisma/pkg/gui/types"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
+ "github.com/dokadev/lazyprisma/pkg/prisma"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazycore/pkg/boxlayout"
+)
+
+// Frame and title styling constants (matching app.panel.go values)
+var (
+ detailsDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
+
+ detailsPrimaryFrameColor = gocui.ColorWhite
+ detailsFocusedFrameColor = gocui.ColorGreen
+
+ detailsPrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone
+ detailsFocusedTitleColor = gocui.ColorGreen | gocui.AttrBold
+
+ detailsFocusedActiveTabColor = gocui.ColorGreen | gocui.AttrBold
+ detailsPrimaryActiveTabColor = gocui.ColorGreen | gocui.AttrNone
+)
+
+// DetailsContext is the context-based replacement for DetailsPanel.
+// It displays migration details and action-needed information with tabbed navigation.
+type DetailsContext struct {
+ *SimpleContext
+ *ScrollableTrait
+ *TabbedTrait
+
+ g *gocui.Gui
+ tr *i18n.TranslationSet
+
+ // Content fields
+ content string
+ currentMigrationName string
+
+ // Action-needed data
+ actionNeededMigrations []prisma.Migration
+ validationResult *prisma.ValidateResult
+
+ // UI state
+ focused bool
+
+ // Callback-based decoupling (replaces direct App reference)
+ hasActiveModal func() bool
+ onPanelClick func(viewID string)
+}
+
+var _ types.Context = &DetailsContext{}
+var _ types.IScrollableContext = &DetailsContext{}
+
+// DetailsContextOpts holds the options for creating a DetailsContext.
+type DetailsContextOpts struct {
+ Gui *gocui.Gui
+ Tr *i18n.TranslationSet
+ ViewName string
+}
+
+// NewDetailsContext creates a new DetailsContext.
+func NewDetailsContext(opts DetailsContextOpts) *DetailsContext {
+ baseCtx := NewBaseContext(BaseContextOpts{
+ Key: types.ContextKey(opts.ViewName),
+ Kind: types.MAIN_CONTEXT,
+ ViewName: opts.ViewName,
+ Focusable: true,
+ Title: opts.Tr.PanelTitleDetails,
+ })
+
+ simpleCtx := NewSimpleContext(baseCtx)
+
+ tabbedTrait := NewTabbedTrait([]string{opts.Tr.TabDetails})
+
+ dc := &DetailsContext{
+ SimpleContext: simpleCtx,
+ ScrollableTrait: &ScrollableTrait{},
+ TabbedTrait: &tabbedTrait,
+ g: opts.Gui,
+ tr: opts.Tr,
+ content: opts.Tr.DetailsPanelInitialPlaceholder,
+ actionNeededMigrations: []prisma.Migration{},
+ }
+
+ return dc
+}
+
+// ID returns the view identifier (implements Panel interface from app package).
+func (d *DetailsContext) ID() string {
+ return d.GetViewName()
+}
+
+// SetModalCallbacks sets callbacks that replace the direct App reference.
+func (d *DetailsContext) SetModalCallbacks(hasActiveModal func() bool, onPanelClick func(string)) {
+ d.hasActiveModal = hasActiveModal
+ d.onPanelClick = onPanelClick
+}
+
+// Draw renders the details panel (implements Panel interface from app package).
+func (d *DetailsContext) Draw(dim boxlayout.Dimensions) error {
+ v, err := d.g.SetView(d.GetViewName(), dim.X0, dim.Y0, dim.X1, dim.Y1, 0)
+ if err != nil && err.Error() != "unknown view" {
+ return err
+ }
+
+ // Setup view WITHOUT title (tabs replace title)
+ d.SetView(v) // BaseContext
+ d.ScrollableTrait.SetView(v) // ScrollableTrait
+
+ v.Clear()
+ v.Frame = true
+ v.FrameRunes = detailsDefaultFrameRunes
+ v.Wrap = true // Enable word wrap for long lines
+
+ // Set tabs from TabbedTrait
+ v.Tabs = d.TabbedTrait.GetTabs()
+ v.TabIndex = d.TabbedTrait.GetCurrentTabIdx()
+
+ // Set frame and tab colors based on focus
+ tabs := d.TabbedTrait.GetTabs()
+ if d.focused {
+ v.FrameColor = detailsFocusedFrameColor
+ v.TitleColor = detailsFocusedTitleColor
+ if len(tabs) == 1 {
+ v.SelFgColor = detailsFocusedTitleColor // Single tab: treat like title
+ } else {
+ v.SelFgColor = detailsFocusedActiveTabColor // Multiple tabs: use active tab color
+ }
+ } else {
+ v.FrameColor = detailsPrimaryFrameColor
+ v.TitleColor = detailsPrimaryTitleColor
+ if len(tabs) == 1 {
+ v.SelFgColor = detailsPrimaryTitleColor // Single tab: treat like title
+ } else {
+ v.SelFgColor = detailsPrimaryActiveTabColor // Multiple tabs: use active tab color
+ }
+ }
+
+ // Render content based on current tab
+ currentTab := d.TabbedTrait.GetCurrentTab()
+ if currentTab == d.tr.TabActionNeeded {
+ fmt.Fprint(v, d.buildActionNeededContent())
+ } else {
+ fmt.Fprint(v, d.content)
+ }
+
+ // Adjust scroll and apply origin
+ d.ScrollableTrait.AdjustScroll()
+
+ return nil
+}
+
+// OnFocus handles focus gain (implements Panel interface from app package).
+func (d *DetailsContext) OnFocus() {
+ d.focused = true
+ if v := d.GetView(); v != nil {
+ v.FrameColor = detailsFocusedFrameColor
+ v.TitleColor = detailsFocusedTitleColor
+ }
+}
+
+// OnBlur handles focus loss (implements Panel interface from app package).
+func (d *DetailsContext) OnBlur() {
+ d.focused = false
+ if v := d.GetView(); v != nil {
+ v.FrameColor = detailsPrimaryFrameColor
+ v.TitleColor = detailsPrimaryTitleColor
+ }
+}
+
+// SetContent sets the content text directly.
+func (d *DetailsContext) SetContent(content string) {
+ d.content = content
+}
+
+// UpdateFromMigration updates the details panel with migration information.
+func (d *DetailsContext) UpdateFromMigration(migration *prisma.Migration, tabName string) {
+ // Only reset scroll position for Details tab if viewing a different migration
+ if migration != nil && d.currentMigrationName != migration.Name {
+ // Reset Details tab scroll position only
+ d.TabbedTrait.ResetTabOriginYAt(d.tabIdxByName(d.tr.TabDetails))
+ // If currently on Details tab, also update originY
+ if d.TabbedTrait.GetCurrentTab() == d.tr.TabDetails {
+ d.ScrollableTrait.SetOriginY(0)
+ }
+ d.currentMigrationName = migration.Name
+ } else if migration == nil {
+ // Reset Details tab scroll position only
+ d.TabbedTrait.ResetTabOriginYAt(d.tabIdxByName(d.tr.TabDetails))
+ // If currently on Details tab, also update originY
+ if d.TabbedTrait.GetCurrentTab() == d.tr.TabDetails {
+ d.ScrollableTrait.SetOriginY(0)
+ }
+ d.currentMigrationName = ""
+ }
+
+ if migration == nil {
+ d.content = d.tr.DetailsPanelInitialPlaceholder
+ return
+ }
+
+ d.content = d.buildMigrationDetailContent(migration, tabName)
+}
+
+// buildMigrationDetailContent builds the detail content for a given migration.
+func (d *DetailsContext) buildMigrationDetailContent(migration *prisma.Migration, tabName string) string {
+ // Handle different cases (priority: Failed > DB-Only > Checksum Mismatch > Empty)
+
+ // In-Transaction migrations (highest priority)
+ if migration.IsFailed {
+ return d.buildFailedMigrationContent(migration)
+ }
+
+ if tabName == d.tr.TabDBOnly {
+ return d.buildDBOnlyContent(migration)
+ }
+
+ // Checksum mismatch
+ if migration.ChecksumMismatch {
+ return d.buildChecksumMismatchContent(migration)
+ }
+
+ if migration.IsEmpty {
+ return d.buildEmptyMigrationContent(migration)
+ }
+
+ // Normal migration
+ return d.buildNormalMigrationContent(migration)
+}
+
+// buildFailedMigrationContent builds content for failed/in-transaction migrations.
+func (d *DetailsContext) buildFailedMigrationContent(migration *prisma.Migration) string {
+ timestamp, name := detailsParseMigrationName(migration.Name)
+ header := fmt.Sprintf(d.tr.DetailsNameLabel, style.Cyan(name))
+ header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp)
+ if migration.Path != "" {
+ header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path))
+ }
+ header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", style.Cyan(d.tr.MigrationStatusInTransaction))
+
+ // Show down migration availability
+ if migration.HasDownSQL {
+ header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Green(d.tr.DetailsDownMigrationAvailable))
+ } else {
+ header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Red(d.tr.DetailsDownMigrationNotAvailable))
+ }
+
+ // Show started_at if available
+ if migration.StartedAt != nil {
+ header += fmt.Sprintf(d.tr.DetailsStartedAtLabel+"%s\n", migration.StartedAt.Format("2006-01-02 15:04:05"))
+ }
+
+ header += "\n" + style.Yellow(d.tr.DetailsInTransactionWarning)
+ header += "\n" + style.Yellow(d.tr.DetailsNoAdditionalMigrationsWarning)
+ header += "\n\n" + d.tr.DetailsResolveManuallyInstruction
+
+ // Show logs if available
+ if migration.Logs != nil && *migration.Logs != "" {
+ header += "\n" + d.tr.DetailsErrorLogsLabel + "\n" + style.Red(*migration.Logs)
+ }
+
+ // Read and show migration.sql content (if Path is available - not DB-Only)
+ if migration.Path != "" {
+ sqlPath := filepath.Join(migration.Path, "migration.sql")
+ content, err := os.ReadFile(sqlPath)
+ if err == nil {
+ highlightedSQL := detailsHighlightSQL(string(content))
+ result := header + "\n\n" + highlightedSQL
+
+ // Show down.sql if available
+ if migration.HasDownSQL {
+ downSQLPath := filepath.Join(migration.Path, "down.sql")
+ downContent, err := os.ReadFile(downSQLPath)
+ if err == nil {
+ highlightedDownSQL := detailsHighlightSQL(string(downContent))
+ result += "\n\n" + style.Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL
+ }
+ }
+ return result
+ }
+ }
+
+ return header
+}
+
+// buildDBOnlyContent builds content for DB-only migrations.
+func (d *DetailsContext) buildDBOnlyContent(migration *prisma.Migration) string {
+ timestamp, name := detailsParseMigrationName(migration.Name)
+ header := fmt.Sprintf(d.tr.DetailsNameLabel, style.Yellow(name))
+ header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp)
+ header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n\n", style.Red(d.tr.MigrationStatusDBOnly))
+ header += d.tr.DetailsDBOnlyDescription
+ return header
+}
+
+// buildChecksumMismatchContent builds content for checksum mismatch migrations.
+func (d *DetailsContext) buildChecksumMismatchContent(migration *prisma.Migration) string {
+ timestamp, name := detailsParseMigrationName(migration.Name)
+ header := fmt.Sprintf(d.tr.DetailsNameLabel, style.Orange(name))
+ header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp)
+ if migration.Path != "" {
+ header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path))
+ }
+ // Show Applied status with Checksum Mismatch warning
+ statusLine := fmt.Sprintf(d.tr.DetailsStatusLabel+"%s", style.Green(d.tr.MigrationStatusApplied))
+ if migration.AppliedAt != nil {
+ statusLine += fmt.Sprintf(" (%s)", fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05")))
+ }
+ statusLine += fmt.Sprintf(" - %s\n", style.Orange(d.tr.MigrationStatusChecksumMismatch))
+ header += statusLine
+
+ // Show down migration availability
+ if migration.HasDownSQL {
+ header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Green(d.tr.DetailsDownMigrationAvailable))
+ } else {
+ header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Red(d.tr.DetailsDownMigrationNotAvailable))
+ }
+
+ header += "\n" + d.tr.DetailsChecksumModifiedDescription
+ header += d.tr.DetailsChecksumIssuesWarning
+
+ // Show checksum values (in orange for emphasis)
+ header += fmt.Sprintf(d.tr.DetailsLocalChecksumLabel+"%s\n", style.Orange(migration.Checksum))
+ header += fmt.Sprintf(d.tr.DetailsHistoryChecksumLabel+"%s\n", style.Orange(migration.DBChecksum))
+
+ // Read and show migration.sql content
+ sqlPath := filepath.Join(migration.Path, "migration.sql")
+ content, err := os.ReadFile(sqlPath)
+ if err == nil {
+ highlightedSQL := detailsHighlightSQL(string(content))
+ result := header + "\n" + highlightedSQL
+
+ // Show down.sql if available
+ if migration.HasDownSQL {
+ downSQLPath := filepath.Join(migration.Path, "down.sql")
+ downContent, err := os.ReadFile(downSQLPath)
+ if err == nil {
+ highlightedDownSQL := detailsHighlightSQL(string(downContent))
+ result += "\n\n" + style.Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL
+ }
+ }
+ return result
+ }
+
+ return header
+}
+
+// buildEmptyMigrationContent builds content for empty migrations.
+func (d *DetailsContext) buildEmptyMigrationContent(migration *prisma.Migration) string {
+ timestamp, name := detailsParseMigrationName(migration.Name)
+ header := fmt.Sprintf(d.tr.DetailsNameLabel, style.Magenta(name))
+ header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp)
+ if migration.Path != "" {
+ header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path))
+ }
+ header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", style.Red(d.tr.MigrationStatusEmptyMigration))
+
+ // Show down migration availability (even for empty migrations)
+ if migration.HasDownSQL {
+ header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Green(d.tr.DetailsDownMigrationAvailable))
+ } else {
+ header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Red(d.tr.DetailsDownMigrationNotAvailable))
+ }
+
+ header += "\n" + d.tr.DetailsEmptyMigrationDescription
+ header += d.tr.DetailsEmptyMigrationWarning
+ return header
+}
+
+// buildNormalMigrationContent builds content for normal (applied/pending) migrations.
+func (d *DetailsContext) buildNormalMigrationContent(migration *prisma.Migration) string {
+ // Read migration.sql content
+ sqlPath := filepath.Join(migration.Path, "migration.sql")
+ content, err := os.ReadFile(sqlPath)
+ if err != nil {
+ timestamp, name := detailsParseMigrationName(migration.Name)
+ return fmt.Sprintf(d.tr.DetailsNameLabel, name) +
+ fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp) +
+ "\n" + fmt.Sprintf(d.tr.ErrorReadingMigrationSQL, err)
+ }
+
+ // Build header with status
+ timestamp, name := detailsParseMigrationName(migration.Name)
+ var header string
+ if migration.AppliedAt != nil {
+ header = fmt.Sprintf(d.tr.DetailsNameLabel, style.Green(name))
+ header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp)
+ if migration.Path != "" {
+ header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path))
+ }
+ header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s (%s)\n",
+ style.Green(d.tr.MigrationStatusApplied),
+ fmt.Sprintf(d.tr.DetailsAppliedAtLabel, migration.AppliedAt.Format("2006-01-02 15:04:05")))
+ } else {
+ header = fmt.Sprintf(d.tr.DetailsNameLabel, style.Yellow(name))
+ header += fmt.Sprintf(d.tr.DetailsTimestampLabel, timestamp)
+ if migration.Path != "" {
+ header += fmt.Sprintf(d.tr.DetailsPathLabel, detailsGetRelativePath(migration.Path))
+ }
+ header += fmt.Sprintf(d.tr.DetailsStatusLabel+"%s\n", style.Yellow(d.tr.MigrationStatusPending))
+ }
+
+ // Show down migration availability
+ if migration.HasDownSQL {
+ header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Green(d.tr.DetailsDownMigrationAvailable))
+ } else {
+ header += fmt.Sprintf(d.tr.DetailsDownMigrationLabel+"%s\n", style.Red(d.tr.DetailsDownMigrationNotAvailable))
+ }
+
+ // Apply syntax highlighting to SQL content
+ highlightedSQL := detailsHighlightSQL(string(content))
+
+ result := header + "\n" + highlightedSQL
+
+ // Show down.sql if available
+ if migration.HasDownSQL {
+ downSQLPath := filepath.Join(migration.Path, "down.sql")
+ downContent, err := os.ReadFile(downSQLPath)
+ if err == nil {
+ highlightedDownSQL := detailsHighlightSQL(string(downContent))
+ result += "\n\n" + style.Yellow(d.tr.DetailsDownMigrationSQLLabel) + "\n\n" + highlightedDownSQL
+ }
+ }
+
+ return result
+}
+
+// SetActionNeededMigrations receives migration data from outside (App will call this).
+// This replaces the old pattern of directly accessing MigrationsPanel.
+func (d *DetailsContext) SetActionNeededMigrations(migrations []prisma.Migration) {
+ d.actionNeededMigrations = migrations
+}
+
+// LoadActionNeededData loads action-needed data using the internal migrations list and validates schema.
+func (d *DetailsContext) LoadActionNeededData() {
+ // Run schema validation
+ cwd, err := os.Getwd()
+ if err == nil {
+ validateResult, err := prisma.Validate(cwd)
+ if err == nil {
+ d.validationResult = validateResult
+ } else {
+ d.validationResult = nil
+ }
+ } else {
+ d.validationResult = nil
+ }
+
+ d.updateTabs()
+}
+
+// updateTabs rebuilds the tabs list based on available data.
+func (d *DetailsContext) updateTabs() {
+ // Always have Details tab
+ newTabs := []string{d.tr.TabDetails}
+
+ // Add Action-Needed tab if there are migration issues or validation errors
+ hasIssues := len(d.actionNeededMigrations) > 0
+ hasValidationErrors := d.validationResult != nil && !d.validationResult.Valid
+
+ if hasIssues || hasValidationErrors {
+ newTabs = append(newTabs, d.tr.TabActionNeeded)
+ }
+
+ d.TabbedTrait.SetTabs(newTabs)
+}
+
+// buildActionNeededContent builds the content for the Action-Needed tab.
+func (d *DetailsContext) buildActionNeededContent() string {
+ // Count all issues
+ emptyCount := 0
+ mismatchCount := 0
+ var emptyMigrations []prisma.Migration
+ var mismatchMigrations []prisma.Migration
+
+ for _, mig := range d.actionNeededMigrations {
+ if mig.IsEmpty {
+ emptyCount++
+ emptyMigrations = append(emptyMigrations, mig)
+ }
+ if mig.ChecksumMismatch {
+ mismatchCount++
+ mismatchMigrations = append(mismatchMigrations, mig)
+ }
+ }
+
+ validationErrorCount := 0
+ if d.validationResult != nil && !d.validationResult.Valid {
+ validationErrorCount = len(d.validationResult.Errors)
+ if validationErrorCount == 0 {
+ validationErrorCount = 1 // At least one error if validation failed
+ }
+ }
+
+ totalCount := emptyCount + mismatchCount + validationErrorCount
+
+ if totalCount == 0 {
+ return d.tr.ActionNeededNoIssuesMessage
+ }
+
+ var content strings.Builder
+
+ // Header
+ content.WriteString(fmt.Sprintf("%s (%d%s", style.Yellow(d.tr.ActionNeededHeader), totalCount, d.tr.ActionNeededIssueSingular))
+ if totalCount > 1 {
+ content.WriteString(d.tr.ActionNeededIssuePlural)
+ }
+ content.WriteString(")\n\n")
+
+ // Empty Migrations Section
+ if emptyCount > 0 {
+ content.WriteString(strings.Repeat("━", 40) + "\n")
+ content.WriteString(fmt.Sprintf("%s (%d)\n", style.Red(d.tr.ActionNeededEmptyMigrationsHeader), emptyCount))
+ content.WriteString(strings.Repeat("━", 40) + "\n\n")
+
+ content.WriteString(d.tr.ActionNeededEmptyDescription)
+
+ content.WriteString(d.tr.ActionNeededAffectedLabel)
+ for _, mig := range emptyMigrations {
+ _, name := detailsParseMigrationName(mig.Name)
+ content.WriteString(fmt.Sprintf(" • %s\n", style.Red(name)))
+ }
+
+ content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel)
+ content.WriteString(d.tr.ActionNeededAddMigrationSQL)
+ content.WriteString(d.tr.ActionNeededDeleteEmptyFolders)
+ content.WriteString(d.tr.ActionNeededMarkAsBaseline)
+ }
+
+ // Checksum Mismatch Section
+ if mismatchCount > 0 {
+ content.WriteString(strings.Repeat("━", 40) + "\n")
+ content.WriteString(fmt.Sprintf("%s (%d)\n", style.Orange(d.tr.ActionNeededChecksumMismatchHeader), mismatchCount))
+ content.WriteString(strings.Repeat("━", 40) + "\n\n")
+
+ content.WriteString(d.tr.ActionNeededChecksumModifiedDescription)
+
+ content.WriteString(style.Yellow(d.tr.ActionNeededWarningPrefix))
+ content.WriteString(d.tr.ActionNeededEditingInconsistenciesWarning)
+
+ content.WriteString(d.tr.ActionNeededAffectedLabel)
+ for _, mig := range mismatchMigrations {
+ _, name := detailsParseMigrationName(mig.Name)
+ content.WriteString(fmt.Sprintf(" • %s\n", style.Orange(name)))
+ }
+
+ content.WriteString("\n" + d.tr.ActionNeededRecommendedLabel)
+ content.WriteString(d.tr.ActionNeededRevertLocalChanges)
+ content.WriteString(d.tr.ActionNeededCreateNewInstead)
+ content.WriteString(d.tr.ActionNeededContactTeamIfNeeded)
+ }
+
+ // Schema Validation Section
+ if validationErrorCount > 0 {
+ content.WriteString(strings.Repeat("━", 40) + "\n")
+ content.WriteString(fmt.Sprintf("%s (%d)\n", style.Red(d.tr.ActionNeededSchemaValidationErrorsHeader), validationErrorCount))
+ content.WriteString(strings.Repeat("━", 40) + "\n\n")
+
+ content.WriteString(d.tr.ActionNeededSchemaValidationFailedDesc)
+ content.WriteString(d.tr.ActionNeededFixBeforeMigration)
+
+ // Show full validation output (contains detailed error info)
+ if d.validationResult.Output != "" {
+ content.WriteString(style.YellowBold(d.tr.ActionNeededValidationOutputLabel) + "\n")
+ // Display the full output with proper formatting (preserve all line breaks)
+ outputLines := strings.Split(d.validationResult.Output, "\n")
+ for _, line := range outputLines {
+ // Highlight error lines
+ if strings.Contains(line, "Error:") || strings.Contains(line, "error:") {
+ content.WriteString(style.Red(line) + "\n")
+ } else if strings.Contains(line, "-->") {
+ content.WriteString(style.Yellow(line) + "\n")
+ } else {
+ // Preserve empty lines and all other content as-is
+ content.WriteString(line + "\n")
+ }
+ }
+ content.WriteString("\n")
+ }
+
+ content.WriteString(style.YellowBold(d.tr.ActionNeededRecommendedActionsLabel) + "\n")
+ content.WriteString(d.tr.ActionNeededFixSchemaErrors)
+ content.WriteString(d.tr.ActionNeededCheckLineNumbers)
+ content.WriteString(d.tr.ActionNeededReferPrismaDocumentation)
+ }
+
+ return content.String()
+}
+
+// NextTab switches to the next tab with scroll state save/restore.
+func (d *DetailsContext) NextTab() {
+ if len(d.TabbedTrait.GetTabs()) == 0 {
+ return
+ }
+ // Save current scroll position before switching
+ d.TabbedTrait.SaveTabOriginY(d.ScrollableTrait.GetOriginY())
+ d.TabbedTrait.NextTab()
+ // Restore scroll position for new tab
+ d.ScrollableTrait.SetOriginY(d.TabbedTrait.RestoreTabOriginY())
+}
+
+// PrevTab switches to the previous tab with scroll state save/restore.
+func (d *DetailsContext) PrevTab() {
+ if len(d.TabbedTrait.GetTabs()) == 0 {
+ return
+ }
+ // Save current scroll position before switching
+ d.TabbedTrait.SaveTabOriginY(d.ScrollableTrait.GetOriginY())
+ d.TabbedTrait.PrevTab()
+ // Restore scroll position for new tab
+ d.ScrollableTrait.SetOriginY(d.TabbedTrait.RestoreTabOriginY())
+}
+
+// handleTabClick handles mouse click on tab bar.
+func (d *DetailsContext) HandleTabClick(tabIndex int) error {
+ // Ignore if modal is active
+ if d.hasActiveModal != nil && d.hasActiveModal() {
+ return nil
+ }
+
+ // First, switch focus to this panel if not already focused
+ if d.onPanelClick != nil {
+ d.onPanelClick(d.GetViewName())
+ }
+
+ // Ignore if same tab is clicked
+ if tabIndex == d.TabbedTrait.GetCurrentTabIdx() {
+ return nil
+ }
+
+ // Ignore if tab index is out of bounds
+ tabs := d.TabbedTrait.GetTabs()
+ if tabIndex < 0 || tabIndex >= len(tabs) {
+ return nil
+ }
+
+ // Save current tab state
+ d.TabbedTrait.SaveTabOriginY(d.ScrollableTrait.GetOriginY())
+
+ // Switch to clicked tab
+ d.TabbedTrait.SetCurrentTabIdx(tabIndex)
+
+ // Restore scroll position for new tab
+ d.ScrollableTrait.SetOriginY(d.TabbedTrait.RestoreTabOriginY())
+
+ return nil
+}
+
+// ScrollUpByWheel scrolls up by wheel increment (delegates to ScrollableTrait).
+func (d *DetailsContext) ScrollUpByWheel() {
+ d.ScrollableTrait.ScrollUpByWheel()
+}
+
+// ScrollDownByWheel scrolls down by wheel increment (delegates to ScrollableTrait).
+func (d *DetailsContext) ScrollDownByWheel() {
+ d.ScrollableTrait.ScrollDownByWheel()
+}
+
+// ============================================================================
+// Private helpers
+// ============================================================================
+
+// tabIdxByName returns the index of the tab with the given name, or 0 if not found.
+func (d *DetailsContext) tabIdxByName(name string) int {
+ for i, t := range d.TabbedTrait.GetTabs() {
+ if t == name {
+ return i
+ }
+ }
+ return 0
+}
+
+// detailsHighlightSQL applies syntax highlighting to SQL code with line numbers.
+func detailsHighlightSQL(code string) string {
+ // Get SQL lexer
+ lexer := lexers.Get("sql")
+ if lexer == nil {
+ lexer = lexers.Fallback
+ }
+
+ // Get style (dracula is a popular dark theme)
+ chromaStyle := styles.Get("dracula")
+ if chromaStyle == nil {
+ chromaStyle = styles.Fallback
+ }
+
+ // Get terminal formatter with 256 colors
+ formatter := formatters.Get("terminal256")
+ if formatter == nil {
+ formatter = formatters.Fallback
+ }
+
+ // Tokenize and format
+ var buf bytes.Buffer
+ iterator, err := lexer.Tokenise(nil, code)
+ if err != nil {
+ return code // Return original if highlighting fails
+ }
+
+ err = formatter.Format(&buf, chromaStyle, iterator)
+ if err != nil {
+ return code // Return original if highlighting fails
+ }
+
+ // Add line numbers
+ highlighted := buf.String()
+ lines := strings.Split(highlighted, "\n")
+ var result strings.Builder
+
+ for i, line := range lines {
+ if i > 0 {
+ result.WriteString("\n")
+ }
+ // Line number in gray color, right-aligned to 4 digits
+ result.WriteString(style.Gray(fmt.Sprintf("%4d │", i+1)) + " " + line)
+ }
+
+ return result.String()
+}
+
+// detailsParseMigrationName parses a Prisma migration name into timestamp and description.
+// Expected format: YYYYMMDDHHMMSS_description
+// Example: 20231123052950_create_career_table -> "2023-11-23 05:29:50", "create_career_table"
+func detailsParseMigrationName(fullName string) (timestamp, name string) {
+ // Check if name matches expected format (at least 15 chars with underscore at position 14)
+ if len(fullName) > 15 && fullName[14] == '_' {
+ timestampStr := fullName[:14] // "20231123052950"
+ name = fullName[15:] // "create_career_table"
+
+ // Parse timestamp: YYYYMMDDHHMMSS -> YYYY-MM-DD HH:MM:SS
+ if len(timestampStr) == 14 {
+ timestamp = fmt.Sprintf("%s-%s-%s %s:%s:%s",
+ timestampStr[0:4], // YYYY
+ timestampStr[4:6], // MM
+ timestampStr[6:8], // DD
+ timestampStr[8:10], // HH
+ timestampStr[10:12], // mm
+ timestampStr[12:14]) // ss
+ return timestamp, name
+ }
+ }
+
+ // Fallback: couldn't parse, return as-is
+ return "N/A", fullName
+}
+
+// detailsGetRelativePath converts absolute path to relative path from current working directory.
+func detailsGetRelativePath(absPath string) string {
+ if absPath == "" {
+ return ""
+ }
+
+ cwd, err := os.Getwd()
+ if err != nil {
+ return absPath // Fallback to absolute path
+ }
+
+ relPath, err := filepath.Rel(cwd, absPath)
+ if err != nil {
+ return absPath // Fallback to absolute path
+ }
+
+ return relPath
+}
diff --git a/pkg/gui/context/migrations_context.go b/pkg/gui/context/migrations_context.go
new file mode 100644
index 0000000..e385f2c
--- /dev/null
+++ b/pkg/gui/context/migrations_context.go
@@ -0,0 +1,726 @@
+package context
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/dokadev/lazyprisma/pkg/database"
+ "github.com/dokadev/lazyprisma/pkg/gui/style"
+ "github.com/dokadev/lazyprisma/pkg/gui/types"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
+ "github.com/dokadev/lazyprisma/pkg/prisma"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazycore/pkg/boxlayout"
+)
+
+// Frame and title styling constants (matching app.panel.go values)
+var (
+ migDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
+
+ migPrimaryFrameColor = gocui.ColorWhite
+ migFocusedFrameColor = gocui.ColorGreen
+
+ migPrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone
+ migFocusedTitleColor = gocui.ColorGreen | gocui.AttrBold
+
+ // Tab styling
+ migFocusedActiveTabColor = gocui.ColorGreen | gocui.AttrBold
+ migPrimaryActiveTabColor = gocui.ColorGreen | gocui.AttrNone
+
+ // List selection colour
+ migSelectionBgColor = gocui.ColorBlue
+)
+
+// MigrationsContext manages the migrations list with tabs (Local, Pending, DB-Only).
+type MigrationsContext struct {
+ *SimpleContext
+ *ScrollableTrait
+ *TabbedTrait
+
+ g *gocui.Gui
+ tr *i18n.TranslationSet
+ focused bool
+
+ // Data
+ category prisma.MigrationCategory // Categorised migrations
+ items []string // Current tab's rendered display strings
+ selected int // Selected item index in current tab
+ dbClient *database.Client // Database connection
+ dbConnected bool // True if connected to database
+ tableExists bool // True if _prisma_migrations table exists
+
+ // Per-tab state preservation
+ tabSelectedMap map[string]int // Last selected index per tab (keyed by tab name)
+ tabOriginYMap map[string]int // Last scroll position per tab (keyed by tab name)
+
+ // Callbacks (replace direct panel/app references)
+ onSelectionChanged func(migration *prisma.Migration, tabName string)
+ hasActiveModal func() bool
+ onPanelClick func(viewID string)
+}
+
+var _ types.Context = &MigrationsContext{}
+
+type MigrationsContextOpts struct {
+ Gui *gocui.Gui
+ Tr *i18n.TranslationSet
+ ViewName string
+}
+
+func NewMigrationsContext(opts MigrationsContextOpts) *MigrationsContext {
+ baseCtx := NewBaseContext(BaseContextOpts{
+ Key: types.ContextKey(opts.ViewName),
+ Kind: types.SIDE_CONTEXT,
+ ViewName: opts.ViewName,
+ Focusable: true,
+ })
+
+ simpleCtx := NewSimpleContext(baseCtx)
+
+ mc := &MigrationsContext{
+ SimpleContext: simpleCtx,
+ ScrollableTrait: &ScrollableTrait{},
+ g: opts.Gui,
+ tr: opts.Tr,
+ items: []string{},
+ selected: 0,
+ tabSelectedMap: make(map[string]int),
+ tabOriginYMap: make(map[string]int),
+ }
+
+ // Initialise TabbedTrait with empty tabs (loadMigrations will populate)
+ tt := NewTabbedTrait([]string{})
+ mc.TabbedTrait = &tt
+
+ mc.loadMigrations()
+ return mc
+}
+
+// ---------------------------------------------------------------------------
+// Callback setters
+// ---------------------------------------------------------------------------
+
+// SetOnSelectionChanged registers a callback invoked whenever the selected
+// migration changes (replaces the old SetDetailsPanel coupling).
+func (m *MigrationsContext) SetOnSelectionChanged(cb func(*prisma.Migration, string)) {
+ m.onSelectionChanged = cb
+}
+
+// SetModalCallbacks registers callbacks for modal and panel-click checks
+// (replaces the old SetApp coupling).
+func (m *MigrationsContext) SetModalCallbacks(hasActiveModal func() bool, onPanelClick func(string)) {
+ m.hasActiveModal = hasActiveModal
+ m.onPanelClick = onPanelClick
+}
+
+// ---------------------------------------------------------------------------
+// Public accessors
+// ---------------------------------------------------------------------------
+
+// ID returns the view identifier (Panel interface compatibility).
+func (m *MigrationsContext) ID() string {
+ return m.GetViewName()
+}
+
+// GetSelectedMigration returns the currently selected migration, or nil.
+func (m *MigrationsContext) GetSelectedMigration() *prisma.Migration {
+ tabName := m.TabbedTrait.GetCurrentTab()
+ if tabName == "" {
+ return nil
+ }
+
+ migrations := m.migrationsForTab(tabName)
+
+ if m.selected >= 0 && m.selected < len(migrations) {
+ return &migrations[m.selected]
+ }
+ return nil
+}
+
+// GetCurrentTabName returns the name of the active tab.
+func (m *MigrationsContext) GetCurrentTabName() string {
+ return m.TabbedTrait.GetCurrentTab()
+}
+
+// GetCategory exposes the full migration category for external use.
+func (m *MigrationsContext) GetCategory() prisma.MigrationCategory {
+ return m.category
+}
+
+// IsDBConnected returns whether the database connection is active.
+func (m *MigrationsContext) IsDBConnected() bool {
+ return m.dbConnected
+}
+
+// ---------------------------------------------------------------------------
+// Focus / Blur
+// ---------------------------------------------------------------------------
+
+// OnFocus handles focus gain (Panel interface compatibility).
+func (m *MigrationsContext) OnFocus() {
+ m.focused = true
+ if v := m.BaseContext.GetView(); v != nil {
+ v.FrameColor = migFocusedFrameColor
+ v.TitleColor = migFocusedTitleColor
+ }
+}
+
+// OnBlur handles focus loss (Panel interface compatibility).
+func (m *MigrationsContext) OnBlur() {
+ m.focused = false
+ if v := m.BaseContext.GetView(); v != nil {
+ v.FrameColor = migPrimaryFrameColor
+ v.TitleColor = migPrimaryTitleColor
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Draw
+// ---------------------------------------------------------------------------
+
+// Draw renders the migrations panel (Panel interface compatibility).
+func (m *MigrationsContext) Draw(dim boxlayout.Dimensions) error {
+ v, err := m.g.SetView(m.GetViewName(), dim.X0, dim.Y0, dim.X1, dim.Y1, 0)
+ if err != nil && err.Error() != "unknown view" {
+ return err
+ }
+
+ // Store view references
+ m.BaseContext.SetView(v)
+ m.ScrollableTrait.SetView(v)
+
+ v.Clear()
+ v.Frame = true
+ v.FrameRunes = migDefaultFrameRunes
+
+ // Set tabs
+ tabs := m.TabbedTrait.GetTabs()
+ v.Tabs = tabs
+ v.TabIndex = m.TabbedTrait.GetCurrentTabIdx()
+
+ // Footer
+ footer := m.buildFooter()
+ v.Footer = footer
+ v.Subtitle = ""
+
+ // Frame and tab colours based on focus
+ if m.focused {
+ v.FrameColor = migFocusedFrameColor
+ v.TitleColor = migFocusedTitleColor
+ if len(tabs) == 1 {
+ v.SelFgColor = migFocusedTitleColor
+ } else {
+ v.SelFgColor = migFocusedActiveTabColor
+ }
+ } else {
+ v.FrameColor = migPrimaryFrameColor
+ v.TitleColor = migPrimaryTitleColor
+ if len(tabs) == 1 {
+ v.SelFgColor = migPrimaryTitleColor
+ } else {
+ v.SelFgColor = migPrimaryActiveTabColor
+ }
+ }
+
+ // Enable highlight for selection
+ v.Highlight = true
+ v.SelBgColor = migSelectionBgColor
+
+ // Render items
+ for _, item := range m.items {
+ fmt.Fprintln(v, item)
+ }
+
+ // Adjust origin to ensure it's within valid bounds
+ m.adjustOrigin(v)
+
+ // Set cursor position to selected item
+ v.SetCursor(0, m.selected-m.ScrollableTrait.GetOriginY())
+ v.SetOrigin(0, m.ScrollableTrait.GetOriginY())
+
+ return nil
+}
+
+// adjustOrigin clamps the scroll origin within valid bounds.
+func (m *MigrationsContext) adjustOrigin(v *gocui.View) {
+ if v == nil {
+ return
+ }
+
+ contentLines := len(v.ViewBufferLines())
+ _, viewHeight := v.Size()
+ innerHeight := viewHeight - 2
+
+ maxOrigin := contentLines - innerHeight
+ if maxOrigin < 0 {
+ maxOrigin = 0
+ }
+
+ originY := m.ScrollableTrait.GetOriginY()
+ if originY > maxOrigin {
+ m.ScrollableTrait.SetOriginY(maxOrigin)
+ }
+}
+
+// buildFooter builds the footer text (selection info in "n of n" format).
+func (m *MigrationsContext) buildFooter() string {
+ if len(m.items) == 0 || (len(m.items) == 1 && m.items[0] == m.tr.ErrorNoMigrationsFound) {
+ return ""
+ }
+ return fmt.Sprintf(m.tr.MigrationsFooterFormat, m.selected+1, len(m.items))
+}
+
+// ---------------------------------------------------------------------------
+// Selection
+// ---------------------------------------------------------------------------
+
+// SelectNext moves the selection down by one.
+func (m *MigrationsContext) SelectNext() {
+ if len(m.items) == 0 {
+ return
+ }
+
+ if m.selected < len(m.items)-1 {
+ m.selected++
+
+ // Auto-scroll if needed
+ if v := m.BaseContext.GetView(); v != nil {
+ _, h := v.Size()
+ innerHeight := h - 2
+ originY := m.ScrollableTrait.GetOriginY()
+ if m.selected-originY >= innerHeight {
+ m.ScrollableTrait.SetOriginY(originY + 1)
+ }
+ }
+
+ m.notifySelectionChanged()
+ }
+}
+
+// SelectPrev moves the selection up by one.
+func (m *MigrationsContext) SelectPrev() {
+ if len(m.items) == 0 {
+ return
+ }
+
+ if m.selected > 0 {
+ m.selected--
+
+ // Auto-scroll if needed
+ originY := m.ScrollableTrait.GetOriginY()
+ if m.selected < originY {
+ m.ScrollableTrait.SetOriginY(originY - 1)
+ }
+
+ m.notifySelectionChanged()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Scroll overrides (list-aware: also update selection)
+// ---------------------------------------------------------------------------
+
+// ScrollToTop scrolls to the top of the list and selects the first item.
+func (m *MigrationsContext) ScrollToTop() {
+ if len(m.items) == 0 {
+ return
+ }
+ m.selected = 0
+ m.ScrollableTrait.SetOriginY(0)
+ m.notifySelectionChanged()
+}
+
+// ScrollToBottom scrolls to the bottom of the list and selects the last item.
+func (m *MigrationsContext) ScrollToBottom() {
+ if len(m.items) == 0 {
+ return
+ }
+
+ maxIndex := len(m.items) - 1
+ m.selected = maxIndex
+
+ if v := m.BaseContext.GetView(); v != nil {
+ _, h := v.Size()
+ innerHeight := h - 2
+ newOriginY := maxIndex - innerHeight + 1
+ if newOriginY < 0 {
+ newOriginY = 0
+ }
+ m.ScrollableTrait.SetOriginY(newOriginY)
+ }
+
+ m.notifySelectionChanged()
+}
+
+// ScrollUpByWheel scrolls the view up by 2 lines (mouse wheel).
+func (m *MigrationsContext) ScrollUpByWheel() {
+ m.ScrollableTrait.ScrollUpByWheel()
+}
+
+// ScrollDownByWheel scrolls the view down by 2 lines (mouse wheel).
+func (m *MigrationsContext) ScrollDownByWheel() {
+ if m.BaseContext.GetView() == nil || len(m.items) == 0 {
+ return
+ }
+
+ contentLines := len(m.items)
+ v := m.BaseContext.GetView()
+ _, viewHeight := v.Size()
+ innerHeight := viewHeight - 2
+
+ maxOrigin := contentLines - innerHeight
+ if maxOrigin < 0 {
+ maxOrigin = 0
+ }
+
+ originY := m.ScrollableTrait.GetOriginY()
+ if originY < maxOrigin {
+ newOriginY := originY + 2
+ if newOriginY > maxOrigin {
+ newOriginY = maxOrigin
+ }
+ m.ScrollableTrait.SetOriginY(newOriginY)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tab switching (custom logic wrapping TabbedTrait)
+// ---------------------------------------------------------------------------
+
+// NextTab switches to the next tab, saving and restoring per-tab state.
+func (m *MigrationsContext) NextTab() {
+ tabs := m.TabbedTrait.GetTabs()
+ if len(tabs) == 0 {
+ return
+ }
+
+ m.saveCurrentTabState()
+ m.TabbedTrait.NextTab()
+ m.loadItemsForCurrentTab()
+}
+
+// PrevTab switches to the previous tab, saving and restoring per-tab state.
+func (m *MigrationsContext) PrevTab() {
+ tabs := m.TabbedTrait.GetTabs()
+ if len(tabs) == 0 {
+ return
+ }
+
+ m.saveCurrentTabState()
+ m.TabbedTrait.PrevTab()
+ m.loadItemsForCurrentTab()
+}
+
+// ---------------------------------------------------------------------------
+// Mouse handlers
+// ---------------------------------------------------------------------------
+
+// HandleTabClick handles mouse click on a tab.
+func (m *MigrationsContext) HandleTabClick(tabIndex int) error {
+ if m.hasActiveModal != nil && m.hasActiveModal() {
+ return nil
+ }
+
+ // Switch focus to this panel if not already focused
+ if m.onPanelClick != nil {
+ m.onPanelClick(m.GetViewName())
+ }
+
+ // Ignore if same tab or out of bounds
+ if tabIndex == m.TabbedTrait.GetCurrentTabIdx() {
+ return nil
+ }
+ tabs := m.TabbedTrait.GetTabs()
+ if tabIndex < 0 || tabIndex >= len(tabs) {
+ return nil
+ }
+
+ m.saveCurrentTabState()
+ m.TabbedTrait.SetCurrentTabIdx(tabIndex)
+ m.loadItemsForCurrentTab()
+
+ return nil
+}
+
+// HandleListClick handles mouse click on a list item.
+func (m *MigrationsContext) HandleListClick(y int) error {
+ if m.hasActiveModal != nil && m.hasActiveModal() {
+ return nil
+ }
+
+ if len(m.items) == 0 {
+ return nil
+ }
+
+ clickedIndex := y
+ if clickedIndex < 0 || clickedIndex >= len(m.items) {
+ return nil
+ }
+
+ m.selected = clickedIndex
+ m.notifySelectionChanged()
+
+ // Switch focus to this panel if not already focused
+ if m.onPanelClick != nil {
+ m.onPanelClick(m.GetViewName())
+ }
+
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Refresh
+// ---------------------------------------------------------------------------
+
+// Refresh reloads all migration data, preserving current tab and selection
+// where possible.
+func (m *MigrationsContext) Refresh() {
+ currentTabIdx := m.TabbedTrait.GetCurrentTabIdx()
+ currentSelected := m.selected
+ currentOriginY := m.ScrollableTrait.GetOriginY()
+
+ // Save current tab state before refresh
+ tabs := m.TabbedTrait.GetTabs()
+ if currentTabIdx < len(tabs) {
+ tabName := tabs[currentTabIdx]
+ m.tabSelectedMap[tabName] = currentSelected
+ m.tabOriginYMap[tabName] = currentOriginY
+ }
+
+ // Reload migrations
+ m.loadMigrations()
+
+ // Restore tab index if still valid
+ newTabs := m.TabbedTrait.GetTabs()
+ if currentTabIdx < len(newTabs) {
+ m.TabbedTrait.SetCurrentTabIdx(currentTabIdx)
+ } else {
+ m.TabbedTrait.SetCurrentTabIdx(0)
+ }
+
+ // Reload items for current tab
+ m.loadItemsForCurrentTab()
+
+ // Restore selection if still valid
+ if currentSelected < len(m.items) {
+ m.selected = currentSelected
+ m.ScrollableTrait.SetOriginY(currentOriginY)
+ m.notifySelectionChanged()
+ } else if len(m.items) > 0 {
+ m.selected = len(m.items) - 1
+ m.ScrollableTrait.SetOriginY(0)
+ m.notifySelectionChanged()
+ } else {
+ m.selected = 0
+ m.ScrollableTrait.SetOriginY(0)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+// notifySelectionChanged invokes the onSelectionChanged callback if set.
+func (m *MigrationsContext) notifySelectionChanged() {
+ if m.onSelectionChanged == nil {
+ return
+ }
+ migration := m.GetSelectedMigration()
+ tabName := m.GetCurrentTabName()
+ m.onSelectionChanged(migration, tabName)
+}
+
+// migrationsForTab returns the migration slice for the given tab name.
+func (m *MigrationsContext) migrationsForTab(tabName string) []prisma.Migration {
+ switch tabName {
+ case m.tr.TabLocal:
+ return m.category.Local
+ case m.tr.TabPending:
+ return m.category.Pending
+ case m.tr.TabDBOnly:
+ return m.category.DBOnly
+ }
+ return nil
+}
+
+// saveCurrentTabState saves the current selection and scroll position for the
+// active tab (keyed by tab name).
+func (m *MigrationsContext) saveCurrentTabState() {
+ tabName := m.TabbedTrait.GetCurrentTab()
+ if tabName == "" {
+ return
+ }
+ m.tabSelectedMap[tabName] = m.selected
+ m.tabOriginYMap[tabName] = m.ScrollableTrait.GetOriginY()
+}
+
+// loadMigrations loads local and (optionally) DB migrations and sets up tabs.
+func (m *MigrationsContext) loadMigrations() {
+ cwd, err := os.Getwd()
+ if err != nil {
+ m.items = []string{m.tr.ErrorFailedGetWorkingDirectory}
+ m.TabbedTrait.SetTabs([]string{m.tr.TabLocal})
+ return
+ }
+
+ // Get local migrations
+ localMigrations, err := prisma.GetLocalMigrations(cwd)
+ if err != nil {
+ m.items = []string{fmt.Sprintf(m.tr.ErrorLoadingLocalMigrations, err)}
+ m.TabbedTrait.SetTabs([]string{m.tr.TabLocal})
+ return
+ }
+
+ // Try to connect to database
+ ds, err := prisma.GetDatasource(cwd)
+ var dbMigrations []prisma.DBMigration
+ m.dbConnected = false
+ tableExists := false
+
+ if err == nil && ds.URL != "" {
+ client, err := database.NewClientFromDSN(ds.Provider, ds.URL)
+ if err == nil {
+ if m.dbClient != nil {
+ m.dbClient.Close()
+ }
+ m.dbClient = client
+ dbMigrations, err = prisma.GetDBMigrations(client.DB())
+ if err == nil {
+ m.dbConnected = true
+ tableExists = true
+ } else {
+ // Check if error is due to missing table
+ if isMigMissingTableError(err) {
+ m.dbConnected = true
+ tableExists = false
+ dbMigrations = []prisma.DBMigration{}
+ }
+ }
+ }
+ }
+
+ if m.dbConnected {
+ m.category = prisma.CompareMigrations(localMigrations, dbMigrations)
+
+ tabs := []string{m.tr.TabLocal}
+ if len(m.category.Pending) > 0 {
+ tabs = append(tabs, m.tr.TabPending)
+ }
+ if len(m.category.DBOnly) > 0 {
+ tabs = append(tabs, m.tr.TabDBOnly)
+ }
+ m.TabbedTrait.SetTabs(tabs)
+
+ m.tableExists = tableExists
+ } else {
+ m.category = prisma.MigrationCategory{
+ Local: localMigrations,
+ Pending: []prisma.Migration{},
+ DBOnly: []prisma.Migration{},
+ }
+ m.TabbedTrait.SetTabs([]string{m.tr.TabLocal})
+ m.tableExists = false
+ }
+
+ // Default to first tab
+ m.TabbedTrait.SetCurrentTabIdx(0)
+ m.loadItemsForCurrentTab()
+}
+
+// loadItemsForCurrentTab rebuilds the display items for the active tab and
+// restores any previously saved selection / scroll position.
+func (m *MigrationsContext) loadItemsForCurrentTab() {
+ tabName := m.TabbedTrait.GetCurrentTab()
+ if tabName == "" {
+ m.items = []string{}
+ return
+ }
+
+ migrations := m.migrationsForTab(tabName)
+
+ if len(migrations) == 0 {
+ m.items = []string{m.tr.ErrorNoMigrationsFound}
+ return
+ }
+
+ m.items = make([]string, len(migrations))
+ for i, mig := range migrations {
+ // Parse migration name to show only description (without timestamp)
+ displayName := mig.Name
+ if len(mig.Name) > 15 && mig.Name[14] == '_' {
+ displayName = mig.Name[15:] // Skip YYYYMMDDHHMMSS_ prefix
+ }
+
+ // Add index number with colour based on migration status
+ var indexPrefix string
+ if mig.IsEmpty {
+ indexPrefix = style.Red(fmt.Sprintf("%4d │", i+1)) + " " // Red for empty
+ } else if mig.HasDownSQL {
+ indexPrefix = style.Green(fmt.Sprintf("%4d │", i+1)) + " " // Green for down.sql
+ } else {
+ indexPrefix = style.Gray(fmt.Sprintf("%4d │", i+1)) + " " // Gray for normal
+ }
+
+ // Colour priority: Failed > Checksum Mismatch > Empty > Pending > Normal
+ if mig.IsFailed {
+ m.items[i] = indexPrefix + style.Cyan(displayName)
+ } else if mig.ChecksumMismatch {
+ m.items[i] = indexPrefix + style.Orange(displayName)
+ } else if mig.IsEmpty {
+ m.items[i] = indexPrefix + style.Red(displayName)
+ } else if m.dbConnected && mig.AppliedAt == nil {
+ m.items[i] = indexPrefix + style.Yellow(displayName)
+ } else {
+ m.items[i] = indexPrefix + displayName
+ }
+ }
+
+ // Restore previous selection and scroll position for this tab
+ if prevSelected, exists := m.tabSelectedMap[tabName]; exists {
+ m.selected = prevSelected
+ if m.selected >= len(m.items) {
+ m.selected = len(m.items) - 1
+ }
+ if m.selected < 0 {
+ m.selected = 0
+ }
+ } else {
+ m.selected = 0
+ }
+
+ if prevOriginY, exists := m.tabOriginYMap[tabName]; exists {
+ m.ScrollableTrait.SetOriginY(prevOriginY)
+ } else {
+ m.ScrollableTrait.SetOriginY(0)
+ }
+
+ // Notify selection changed
+ m.notifySelectionChanged()
+}
+
+// isMigMissingTableError checks if an error is due to a missing
+// _prisma_migrations table.
+func isMigMissingTableError(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ errMsg := strings.ToLower(err.Error())
+
+ missingTablePatterns := []string{
+ "does not exist", // PostgreSQL
+ "doesn't exist", // MySQL
+ "no such table", // SQLite
+ "invalid object name", // SQL Server
+ "table or view does not exist", // Oracle
+ }
+
+ for _, pattern := range missingTablePatterns {
+ if strings.Contains(errMsg, pattern) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/gui/context/output_context.go b/pkg/gui/context/output_context.go
new file mode 100644
index 0000000..559539d
--- /dev/null
+++ b/pkg/gui/context/output_context.go
@@ -0,0 +1,199 @@
+package context
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/dokadev/lazyprisma/pkg/gui/style"
+ "github.com/dokadev/lazyprisma/pkg/gui/types"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazycore/pkg/boxlayout"
+)
+
+// Frame and title styling constants (matching app.panel.go values)
+var (
+ outputDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
+
+ outputPrimaryFrameColor = gocui.ColorWhite
+ outputFocusedFrameColor = gocui.ColorGreen
+
+ outputPrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone
+ outputFocusedTitleColor = gocui.ColorGreen | gocui.AttrBold
+)
+
+type OutputContext struct {
+ *SimpleContext
+ *ScrollableTrait
+
+ g *gocui.Gui
+ tr *i18n.TranslationSet
+ content string
+ subtitle string
+ focused bool
+ autoScrollToBottom bool
+}
+
+var _ types.Context = &OutputContext{}
+var _ types.IScrollableContext = &OutputContext{}
+
+type OutputContextOpts struct {
+ Gui *gocui.Gui
+ Tr *i18n.TranslationSet
+ ViewName string
+}
+
+func NewOutputContext(opts OutputContextOpts) *OutputContext {
+ baseCtx := NewBaseContext(BaseContextOpts{
+ Key: types.ContextKey(opts.ViewName),
+ Kind: types.MAIN_CONTEXT,
+ ViewName: opts.ViewName,
+ Focusable: true,
+ Title: opts.Tr.PanelTitleOutput,
+ })
+
+ simpleCtx := NewSimpleContext(baseCtx)
+
+ oc := &OutputContext{
+ SimpleContext: simpleCtx,
+ ScrollableTrait: &ScrollableTrait{},
+ g: opts.Gui,
+ tr: opts.Tr,
+ content: "",
+ }
+
+ return oc
+}
+
+// ID returns the view identifier (implements Panel interface from app package)
+func (o *OutputContext) ID() string {
+ return o.GetViewName()
+}
+
+// Draw renders the output panel (implements Panel interface from app package)
+func (o *OutputContext) Draw(dim boxlayout.Dimensions) error {
+ v, err := o.g.SetView(o.GetViewName(), dim.X0, dim.Y0, dim.X1, dim.Y1, 0)
+ if err != nil && err.Error() != "unknown view" {
+ return err
+ }
+
+ // Setup view (replicates BasePanel.SetupView)
+ o.setupView(v)
+ o.SetView(v) // BaseContext
+ o.ScrollableTrait.SetView(v) // ScrollableTrait
+
+ v.Subtitle = o.subtitle
+ v.Wrap = true
+ fmt.Fprint(v, o.content)
+
+ // Auto-scroll to bottom if flagged
+ if o.autoScrollToBottom {
+ contentLines := len(v.ViewBufferLines())
+ _, viewHeight := v.Size()
+ innerHeight := viewHeight - 2
+ maxOrigin := contentLines - innerHeight
+ if maxOrigin < 0 {
+ maxOrigin = 0
+ }
+ o.ScrollableTrait.SetOriginY(maxOrigin)
+ o.autoScrollToBottom = false
+ }
+
+ // Adjust scroll and apply origin
+ o.ScrollableTrait.AdjustScroll()
+
+ return nil
+}
+
+// setupView configures the view with common settings (replaces BasePanel.SetupView)
+func (o *OutputContext) setupView(v *gocui.View) {
+ v.Clear()
+ v.Frame = true
+ v.Title = o.tr.PanelTitleOutput
+ v.FrameRunes = outputDefaultFrameRunes
+
+ if o.focused {
+ v.FrameColor = outputFocusedFrameColor
+ v.TitleColor = outputFocusedTitleColor
+ } else {
+ v.FrameColor = outputPrimaryFrameColor
+ v.TitleColor = outputPrimaryTitleColor
+ }
+}
+
+// OnFocus handles focus gain (implements Panel interface from app package)
+func (o *OutputContext) OnFocus() {
+ o.focused = true
+ if v := o.GetView(); v != nil {
+ v.FrameColor = outputFocusedFrameColor
+ v.TitleColor = outputFocusedTitleColor
+ }
+}
+
+// OnBlur handles focus loss (implements Panel interface from app package)
+func (o *OutputContext) OnBlur() {
+ o.focused = false
+ if v := o.GetView(); v != nil {
+ v.FrameColor = outputPrimaryFrameColor
+ v.TitleColor = outputPrimaryTitleColor
+ }
+}
+
+// AppendOutput appends text to the output buffer and flags auto-scroll
+func (o *OutputContext) AppendOutput(text string) {
+ o.content += text + "\n"
+ o.autoScrollToBottom = true
+}
+
+// LogAction logs an action with timestamp and optional details
+func (o *OutputContext) LogAction(action string, details ...string) {
+ timestamp := time.Now().Format("15:04:05")
+
+ if o.content != "" {
+ o.content += "\n"
+ }
+
+ header := fmt.Sprintf("%s %s", style.Gray(timestamp), style.CyanBold(action))
+ o.content += header + "\n"
+
+ for _, detail := range details {
+ o.content += " " + detail + "\n"
+ }
+
+ o.autoScrollToBottom = true
+}
+
+// LogActionRed logs an action in red (for errors/warnings)
+func (o *OutputContext) LogActionRed(action string, details ...string) {
+ timestamp := time.Now().Format("15:04:05")
+
+ if o.content != "" {
+ o.content += "\n"
+ }
+
+ header := fmt.Sprintf("%s %s", style.Gray(timestamp), style.RedBold(action))
+ o.content += header + "\n"
+
+ for _, detail := range details {
+ o.content += " " + style.Red(detail) + "\n"
+ }
+
+ o.autoScrollToBottom = true
+}
+
+// SetSubtitle sets the custom subtitle for the panel
+func (o *OutputContext) SetSubtitle(subtitle string) {
+ o.subtitle = subtitle
+}
+
+// ScrollUpByWheel scrolls up by wheel increment (delegates to ScrollableTrait)
+// This method is provided for backward compatibility with existing callers
+// that pass no arguments (the old OutputPanel signature).
+func (o *OutputContext) ScrollUpByWheel() {
+ o.ScrollableTrait.ScrollUpByWheel()
+}
+
+// ScrollDownByWheel scrolls down by wheel increment (delegates to ScrollableTrait)
+func (o *OutputContext) ScrollDownByWheel() {
+ o.ScrollableTrait.ScrollDownByWheel()
+}
diff --git a/pkg/gui/context/scrollable_trait.go b/pkg/gui/context/scrollable_trait.go
new file mode 100644
index 0000000..173219d
--- /dev/null
+++ b/pkg/gui/context/scrollable_trait.go
@@ -0,0 +1,121 @@
+package context
+
+import (
+ "github.com/jesseduffield/gocui"
+)
+
+const wheelScrollLines = 2
+
+// ScrollableTrait provides shared vertical scroll logic.
+// It tracks originY manually and applies it to the gocui view,
+// replicating the exact behaviour used across all existing panels.
+type ScrollableTrait struct {
+ view *gocui.View
+ originY int
+}
+
+// SetView assigns (or reassigns) the underlying gocui view.
+func (self *ScrollableTrait) SetView(v *gocui.View) {
+ self.view = v
+}
+
+// GetOriginY returns the current scroll offset.
+func (self *ScrollableTrait) GetOriginY() int {
+ return self.originY
+}
+
+// SetOriginY sets the scroll offset directly (e.g. when restoring tab state).
+func (self *ScrollableTrait) SetOriginY(y int) {
+ self.originY = y
+}
+
+// ScrollUp scrolls the view up by 1 line.
+func (self *ScrollableTrait) ScrollUp() {
+ if self.originY > 0 {
+ self.originY--
+ }
+}
+
+// ScrollDown scrolls the view down by 1 line, clamping to the maximum scrollable position.
+func (self *ScrollableTrait) ScrollDown() {
+ if self.view == nil {
+ return
+ }
+
+ maxOrigin := self.maxOrigin()
+ if self.originY < maxOrigin {
+ self.originY++
+ }
+}
+
+// ScrollUpByWheel scrolls the view up by the wheel increment.
+func (self *ScrollableTrait) ScrollUpByWheel() {
+ if self.originY > 0 {
+ self.originY -= wheelScrollLines
+ if self.originY < 0 {
+ self.originY = 0
+ }
+ }
+}
+
+// ScrollDownByWheel scrolls the view down by the wheel increment,
+// clamping to the maximum scrollable position.
+func (self *ScrollableTrait) ScrollDownByWheel() {
+ if self.view == nil {
+ return
+ }
+
+ maxOrigin := self.maxOrigin()
+ if self.originY < maxOrigin {
+ self.originY += wheelScrollLines
+ if self.originY > maxOrigin {
+ self.originY = maxOrigin
+ }
+ }
+}
+
+// ScrollToTop scrolls to the very top.
+func (self *ScrollableTrait) ScrollToTop() {
+ self.originY = 0
+}
+
+// ScrollToBottom scrolls to the very bottom.
+func (self *ScrollableTrait) ScrollToBottom() {
+ if self.view == nil {
+ return
+ }
+
+ maxOrigin := self.maxOrigin()
+ self.originY = maxOrigin
+}
+
+// AdjustScroll clamps originY to valid bounds and applies it to the view.
+// Call this during render after content has been written to the view.
+func (self *ScrollableTrait) AdjustScroll() {
+ if self.view == nil {
+ return
+ }
+
+ maxOrigin := self.maxOrigin()
+ if self.originY > maxOrigin {
+ self.originY = maxOrigin
+ }
+ if self.originY < 0 {
+ self.originY = 0
+ }
+
+ self.view.SetOrigin(0, self.originY)
+}
+
+// maxOrigin calculates the maximum valid originY based on content and view size.
+func (self *ScrollableTrait) maxOrigin() int {
+ contentLines := len(self.view.ViewBufferLines())
+ _, viewHeight := self.view.Size()
+ innerHeight := viewHeight - 2 // Exclude frame (top + bottom)
+
+ max := contentLines - innerHeight
+ if max < 0 {
+ max = 0
+ }
+ return max
+}
diff --git a/pkg/gui/context/simple_context.go b/pkg/gui/context/simple_context.go
new file mode 100644
index 0000000..631955f
--- /dev/null
+++ b/pkg/gui/context/simple_context.go
@@ -0,0 +1,46 @@
+package context
+
+import (
+ "github.com/dokadev/lazyprisma/pkg/gui/types"
+)
+
+type SimpleContext struct {
+ *BaseContext
+ onRenderFn func()
+}
+
+var _ types.Context = &SimpleContext{}
+
+func NewSimpleContext(baseContext *BaseContext) *SimpleContext {
+ return &SimpleContext{
+ BaseContext: baseContext,
+ }
+}
+
+// SetOnRenderFn sets the function called during HandleRender.
+func (self *SimpleContext) SetOnRenderFn(fn func()) {
+ self.onRenderFn = fn
+}
+
+// HandleFocus is called when this context gains focus.
+// It invokes all registered onFocusFns in order.
+func (self *SimpleContext) HandleFocus(opts types.OnFocusOpts) {
+ for _, fn := range self.onFocusFns {
+ fn(opts)
+ }
+}
+
+// HandleFocusLost is called when this context loses focus.
+// It invokes all registered onFocusLostFns in order.
+func (self *SimpleContext) HandleFocusLost(opts types.OnFocusLostOpts) {
+ for _, fn := range self.onFocusLostFns {
+ fn(opts)
+ }
+}
+
+// HandleRender is called when the context needs to re-render its content.
+func (self *SimpleContext) HandleRender() {
+ if self.onRenderFn != nil {
+ self.onRenderFn()
+ }
+}
diff --git a/pkg/gui/context/statusbar_context.go b/pkg/gui/context/statusbar_context.go
new file mode 100644
index 0000000..636f9e8
--- /dev/null
+++ b/pkg/gui/context/statusbar_context.go
@@ -0,0 +1,164 @@
+package context
+
+import (
+ "fmt"
+
+ "github.com/dokadev/lazyprisma/pkg/gui/style"
+ "github.com/dokadev/lazyprisma/pkg/gui/types"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazycore/pkg/boxlayout"
+)
+
+// StatusBarState provides callbacks for accessing App state without direct dependency.
+type StatusBarState struct {
+ IsCommandRunning func() bool
+ GetSpinnerFrame func() uint32
+ IsStudioRunning func() bool
+ GetCommandName func() string
+}
+
+// StatusBarConfig holds static configuration for the status bar display.
+type StatusBarConfig struct {
+ Developer string
+ Version string
+}
+
+var spinnerFrames = []rune{'|', '/', '-', '\\'}
+
+// SpinnerFrameCount returns the number of spinner animation frames.
+func SpinnerFrameCount() uint32 {
+ return uint32(len(spinnerFrames))
+}
+
+type StatusBarContext struct {
+ *BaseContext
+
+ g *gocui.Gui
+ tr *i18n.TranslationSet
+ state StatusBarState
+ config StatusBarConfig
+}
+
+type StatusBarContextOpts struct {
+ Gui *gocui.Gui
+ Tr *i18n.TranslationSet
+ ViewName string
+ State StatusBarState
+ Config StatusBarConfig
+}
+
+func NewStatusBarContext(opts StatusBarContextOpts) *StatusBarContext {
+ baseCtx := NewBaseContext(BaseContextOpts{
+ Key: types.ContextKey(opts.ViewName),
+ Kind: types.MAIN_CONTEXT,
+ ViewName: opts.ViewName,
+ Focusable: false,
+ Title: "",
+ })
+
+ return &StatusBarContext{
+ BaseContext: baseCtx,
+ g: opts.Gui,
+ tr: opts.Tr,
+ state: opts.State,
+ config: opts.Config,
+ }
+}
+
+// ID returns the view identifier (implements Panel interface from app package)
+func (s *StatusBarContext) ID() string {
+ return s.GetViewName()
+}
+
+// Draw renders the status bar (implements Panel interface from app package)
+func (s *StatusBarContext) Draw(dim boxlayout.Dimensions) error {
+ // StatusBar has no frame, so adjust dimensions
+ frameOffset := 1
+ x0 := dim.X0 - frameOffset
+ y0 := dim.Y0 - frameOffset
+ x1 := dim.X1 + frameOffset
+ y1 := dim.Y1 + frameOffset
+
+ v, err := s.g.SetView(s.GetViewName(), x0, y0, x1, y1, 0)
+ if err != nil && err.Error() != "unknown view" {
+ return err
+ }
+
+ s.SetView(v)
+ v.Clear()
+ v.Frame = false
+
+ // Build status bar content
+ var leftContent string
+ var visibleLen int
+
+ // Show spinner if command is running
+ if s.state.IsCommandRunning() {
+ frameIndex := s.state.GetSpinnerFrame() % uint32(len(spinnerFrames))
+ spinner := string(spinnerFrames[frameIndex])
+
+ // Get running task name
+ taskName := s.state.GetCommandName()
+
+ leftContent = fmt.Sprintf(" %s %s ", style.Cyan(spinner), style.Gray(taskName))
+ visibleLen += 1 + 1 + 1 + len(taskName) + 1 // " " + spinner + " " + taskName + " "
+ } else {
+ leftContent = " " // Single space when not running
+ visibleLen += 1
+ }
+
+ // Show Studio status if running
+ if s.state.IsStudioRunning() {
+ studioMsg := s.tr.StatusStudioOn
+ leftContent += fmt.Sprintf("%s ", style.Green(studioMsg))
+ visibleLen += len(studioMsg) + 1
+ }
+
+ // Helper to format key binding: [k]ey -> [Cyan(k)]Gray(ey)
+ // Returns styled string and its visible length
+ appendKey := func(key, desc string) {
+ // Style: [key]desc
+ styled := fmt.Sprintf("[%s]%s", style.Cyan(key), style.Gray(desc))
+ // Visible: [key]desc
+ vLen := 1 + len(key) + 1 + len(desc)
+
+ leftContent += styled + " "
+ visibleLen += vLen + 1
+ }
+
+ appendKey("r", s.tr.KeyHintRefresh)
+ appendKey("d", s.tr.KeyHintDev)
+ appendKey("D", s.tr.KeyHintDeploy)
+ appendKey("g", s.tr.KeyHintGenerate)
+ appendKey("s", s.tr.KeyHintResolve)
+ appendKey("S", s.tr.KeyHintStudio)
+ appendKey("c", s.tr.KeyHintCopy)
+
+ // Right content (Metadata)
+ styledRight := fmt.Sprintf("%s %s", style.Blue(s.config.Developer), style.Gray(s.config.Version))
+ rightLen := len(s.config.Developer) + 1 + len(s.config.Version)
+
+ // Calculate padding
+ viewWidth, _ := v.Size()
+ paddingLen := viewWidth - visibleLen - rightLen - 2 // -2 for extra safety buffer
+
+ if paddingLen < 1 {
+ paddingLen = 1
+ }
+
+ padding := ""
+ for i := 0; i < paddingLen; i++ {
+ padding += " "
+ }
+
+ fmt.Fprint(v, leftContent+padding+styledRight)
+
+ return nil
+}
+
+// OnFocus is a no-op for the status bar (not focusable)
+func (s *StatusBarContext) OnFocus() {}
+
+// OnBlur is a no-op for the status bar (not focusable)
+func (s *StatusBarContext) OnBlur() {}
diff --git a/pkg/gui/context/tabbed_trait.go b/pkg/gui/context/tabbed_trait.go
new file mode 100644
index 0000000..232ca2b
--- /dev/null
+++ b/pkg/gui/context/tabbed_trait.go
@@ -0,0 +1,93 @@
+package context
+
+// TabbedTrait provides shared tab management with per-tab scroll position saving.
+// It replicates the exact tab origin save/restore pattern used in
+// MigrationsPanel and DetailsPanel.
+type TabbedTrait struct {
+ tabs []string
+ currentTabIdx int
+ tabOriginY map[int]int // scroll position keyed by tab index
+}
+
+// NewTabbedTrait creates a TabbedTrait with the given initial tabs.
+func NewTabbedTrait(tabs []string) TabbedTrait {
+ return TabbedTrait{
+ tabs: tabs,
+ currentTabIdx: 0,
+ tabOriginY: make(map[int]int),
+ }
+}
+
+// SetTabs replaces the tab list. If the current index is out of bounds
+// after the change, it resets to 0.
+func (self *TabbedTrait) SetTabs(tabs []string) {
+ self.tabs = tabs
+ if self.currentTabIdx >= len(self.tabs) {
+ self.currentTabIdx = 0
+ }
+}
+
+// GetTabs returns the current tab names.
+func (self *TabbedTrait) GetTabs() []string {
+ return self.tabs
+}
+
+// GetCurrentTab returns the name of the active tab,
+// or an empty string if there are no tabs.
+func (self *TabbedTrait) GetCurrentTab() string {
+ if self.currentTabIdx >= len(self.tabs) {
+ return ""
+ }
+ return self.tabs[self.currentTabIdx]
+}
+
+// GetCurrentTabIdx returns the zero-based index of the active tab.
+func (self *TabbedTrait) GetCurrentTabIdx() int {
+ return self.currentTabIdx
+}
+
+// SetCurrentTabIdx sets the active tab index directly.
+func (self *TabbedTrait) SetCurrentTabIdx(idx int) {
+ if idx >= 0 && idx < len(self.tabs) {
+ self.currentTabIdx = idx
+ }
+}
+
+// NextTab advances to the next tab, wrapping around.
+// The caller must call SaveTabOriginY before and RestoreTabOriginY after.
+func (self *TabbedTrait) NextTab() {
+ if len(self.tabs) == 0 {
+ return
+ }
+ self.currentTabIdx = (self.currentTabIdx + 1) % len(self.tabs)
+}
+
+// PrevTab moves to the previous tab, wrapping around.
+// The caller must call SaveTabOriginY before and RestoreTabOriginY after.
+func (self *TabbedTrait) PrevTab() {
+ if len(self.tabs) == 0 {
+ return
+ }
+ self.currentTabIdx = (self.currentTabIdx - 1 + len(self.tabs)) % len(self.tabs)
+}
+
+// SaveTabOriginY saves the given scroll position for the current tab.
+// Call this before switching tabs.
+func (self *TabbedTrait) SaveTabOriginY(originY int) {
+ self.tabOriginY[self.currentTabIdx] = originY
+}
+
+// ResetTabOriginYAt resets the saved scroll position for the tab at the given index.
+func (self *TabbedTrait) ResetTabOriginYAt(idx int) {
+ self.tabOriginY[idx] = 0
+}
+
+// RestoreTabOriginY returns the saved scroll position for the current tab.
+// Returns 0 if no position was previously saved.
+// Call this after switching tabs.
+func (self *TabbedTrait) RestoreTabOriginY() int {
+ if y, exists := self.tabOriginY[self.currentTabIdx]; exists {
+ return y
+ }
+ return 0
+}
diff --git a/pkg/gui/context/workspace_context.go b/pkg/gui/context/workspace_context.go
new file mode 100644
index 0000000..5a31f16
--- /dev/null
+++ b/pkg/gui/context/workspace_context.go
@@ -0,0 +1,395 @@
+package context
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/dokadev/lazyprisma/pkg/database"
+ "github.com/dokadev/lazyprisma/pkg/git"
+ "github.com/dokadev/lazyprisma/pkg/gui/style"
+ "github.com/dokadev/lazyprisma/pkg/gui/types"
+ "github.com/dokadev/lazyprisma/pkg/i18n"
+ "github.com/dokadev/lazyprisma/pkg/node"
+ "github.com/dokadev/lazyprisma/pkg/prisma"
+ "github.com/jesseduffield/gocui"
+ "github.com/jesseduffield/lazycore/pkg/boxlayout"
+)
+
+// Frame and title styling constants (matching app.panel.go values)
+var (
+ wsDefaultFrameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'}
+
+ wsPrimaryFrameColor = gocui.ColorWhite
+ wsFocusedFrameColor = gocui.ColorGreen
+
+ wsPrimaryTitleColor = gocui.ColorWhite | gocui.AttrNone
+ wsFocusedTitleColor = gocui.ColorGreen | gocui.AttrBold
+)
+
+type WorkspaceContext struct {
+ *SimpleContext
+ *ScrollableTrait
+
+ g *gocui.Gui
+ tr *i18n.TranslationSet
+ focused bool
+ nodeVersion string
+ prismaVersion string
+ prismaGlobal bool
+ gitRepoName string // Git repository name
+ gitBranch string // Git branch name
+ isGitRepo bool // True if current directory is a git repository
+ schemaModified bool // True if schema.prisma has git changes
+ unmaskedURL string
+ maskedURL string
+ showMasked bool
+ dbProvider string
+ dbConnected bool
+ dbError string
+ dbConfigError bool
+ envVarName string // Environment variable name (e.g., "DATABASE_URL")
+ isHardcoded bool // True if URL is hardcoded in schema/config
+}
+
+var _ types.Context = &WorkspaceContext{}
+var _ types.IScrollableContext = &WorkspaceContext{}
+
+type WorkspaceContextOpts struct {
+ Gui *gocui.Gui
+ Tr *i18n.TranslationSet
+ ViewName string
+}
+
+func NewWorkspaceContext(opts WorkspaceContextOpts) *WorkspaceContext {
+ baseCtx := NewBaseContext(BaseContextOpts{
+ Key: types.ContextKey(opts.ViewName),
+ Kind: types.SIDE_CONTEXT,
+ ViewName: opts.ViewName,
+ Focusable: true,
+ Title: opts.Tr.PanelTitleWorkspace,
+ })
+
+ simpleCtx := NewSimpleContext(baseCtx)
+
+ wc := &WorkspaceContext{
+ SimpleContext: simpleCtx,
+ ScrollableTrait: &ScrollableTrait{},
+ g: opts.Gui,
+ tr: opts.Tr,
+ showMasked: true, // Default to masked
+ }
+
+ wc.loadVersionInfo()
+
+ return wc
+}
+
+// ID returns the view identifier (implements Panel interface from app package)
+func (w *WorkspaceContext) ID() string {
+ return w.GetViewName()
+}
+
+// Draw renders the workspace panel (implements Panel interface from app package)
+func (w *WorkspaceContext) Draw(dim boxlayout.Dimensions) error {
+ v, err := w.g.SetView(w.GetViewName(), dim.X0, dim.Y0, dim.X1, dim.Y1, 0)
+ if err != nil && err.Error() != "unknown view" {
+ return err
+ }
+
+ // Setup view (replicates BasePanel.SetupView)
+ w.setupView(v)
+ w.SetView(v) // BaseContext
+ w.ScrollableTrait.SetView(v) // ScrollableTrait
+
+ v.Wrap = true // Enable word wrap
+
+ // Build content from fields
+ var lines []string
+
+ // Node and Prisma version on one line
+ nodeVersionStyled := style.YellowBold(w.nodeVersion)
+ prismaVersionStyled := style.YellowBold(w.prismaVersion)
+ versionLine := fmt.Sprintf(w.tr.WorkspaceVersionLine, nodeVersionStyled, prismaVersionStyled)
+ if w.prismaGlobal {
+ versionLine += " " + style.Orange(w.tr.WorkspacePrismaGlobalIndicator)
+ }
+ lines = append(lines, versionLine)
+
+ // Git info
+ lines = append(lines, "")
+ if w.isGitRepo {
+ // Git line with optional schema modified indicator
+ gitLine := fmt.Sprintf(w.tr.WorkspaceGitLine, w.gitRepoName)
+ if w.schemaModified {
+ gitLine += " " + style.Orange(w.tr.WorkspaceSchemaModifiedIndicator)
+ }
+ lines = append(lines, gitLine)
+
+ // Branch on separate line
+ branchStyled := style.YellowBold(w.gitBranch)
+ lines = append(lines, fmt.Sprintf(w.tr.WorkspaceBranchFormat, branchStyled))
+ } else {
+ lines = append(lines, w.tr.WorkspaceNotGitRepository)
+ }
+
+ lines = append(lines, "")
+ lines = append(lines, w.buildDatabaseLines()...)
+
+ content := ""
+ for _, line := range lines {
+ content += line + "\n"
+ }
+
+ fmt.Fprint(v, content)
+
+ // Adjust scroll and apply origin
+ w.ScrollableTrait.AdjustScroll()
+
+ return nil
+}
+
+// setupView configures the view with common settings (replaces BasePanel.SetupView)
+func (w *WorkspaceContext) setupView(v *gocui.View) {
+ v.Clear()
+ v.Frame = true
+ v.Title = w.tr.PanelTitleWorkspace
+ v.FrameRunes = wsDefaultFrameRunes
+
+ if w.focused {
+ v.FrameColor = wsFocusedFrameColor
+ v.TitleColor = wsFocusedTitleColor
+ } else {
+ v.FrameColor = wsPrimaryFrameColor
+ v.TitleColor = wsPrimaryTitleColor
+ }
+}
+
+// OnFocus handles focus gain (implements Panel interface from app package)
+func (w *WorkspaceContext) OnFocus() {
+ w.focused = true
+ if v := w.GetView(); v != nil {
+ v.FrameColor = wsFocusedFrameColor
+ v.TitleColor = wsFocusedTitleColor
+ }
+}
+
+// OnBlur handles focus loss (implements Panel interface from app package)
+func (w *WorkspaceContext) OnBlur() {
+ w.focused = false
+ if v := w.GetView(); v != nil {
+ v.FrameColor = wsPrimaryFrameColor
+ v.TitleColor = wsPrimaryTitleColor
+ }
+}
+
+// Refresh reloads all workspace information
+func (w *WorkspaceContext) Refresh() {
+ // Save current scroll position
+ currentOriginY := w.ScrollableTrait.GetOriginY()
+
+ // Reload information
+ w.loadVersionInfo()
+ w.loadDatabaseInfo()
+
+ // Restore scroll position (will be adjusted by AdjustScroll in Draw if needed)
+ w.ScrollableTrait.SetOriginY(currentOriginY)
+}
+
+// ScrollUpByWheel scrolls up by wheel increment (delegates to ScrollableTrait)
+func (w *WorkspaceContext) ScrollUpByWheel() {
+ w.ScrollableTrait.ScrollUpByWheel()
+}
+
+// ScrollDownByWheel scrolls down by wheel increment (delegates to ScrollableTrait)
+func (w *WorkspaceContext) ScrollDownByWheel() {
+ w.ScrollableTrait.ScrollDownByWheel()
+}
+
+func (w *WorkspaceContext) loadVersionInfo() {
+ cwd, _ := os.Getwd()
+
+ // Node version
+ if nodeVer, err := node.GetVersion(); err == nil {
+ w.nodeVersion = nodeVer.Version
+ } else {
+ w.nodeVersion = w.tr.WorkspaceVersionNotFound
+ }
+
+ // Prisma version
+ if prismaVer, err := prisma.GetVersion(cwd); err == nil {
+ w.prismaVersion = prismaVer.Version
+ w.prismaGlobal = prismaVer.IsGlobal
+ } else {
+ w.prismaVersion = w.tr.WorkspaceVersionNotFound
+ w.prismaGlobal = false
+ }
+
+ // Git info
+ gitInfo := git.GetGitInfo(cwd)
+ w.isGitRepo = gitInfo.IsRepository
+ w.gitRepoName = gitInfo.RepositoryName
+ w.gitBranch = gitInfo.BranchName
+
+ // Check schema.prisma modification status (only if git repo)
+ if w.isGitRepo {
+ schemaPath := filepath.Join(cwd, prisma.SchemaDirName, prisma.SchemaFileName)
+ w.schemaModified = git.IsFileModified(cwd, schemaPath)
+ } else {
+ w.schemaModified = false
+ }
+
+ // Load database info
+ w.loadDatabaseInfo()
+}
+
+func (w *WorkspaceContext) loadDatabaseInfo() {
+ // Reset fields
+ w.dbProvider = ""
+ w.unmaskedURL = ""
+ w.maskedURL = ""
+ w.dbConnected = false
+ w.dbError = ""
+ w.dbConfigError = false
+ w.envVarName = ""
+ w.isHardcoded = false
+
+ cwd, err := os.Getwd()
+ if err != nil {
+ w.dbError = w.tr.WorkspaceErrorGetWorkingDirectory
+ return
+ }
+
+ // Get datasource from schema
+ ds, err := prisma.GetDatasource(cwd)
+ if err != nil {
+ // Try to extract provider only (even if URL resolution fails)
+ if provider, err2 := prisma.GetProvider(cwd); err2 == nil {
+ w.dbProvider = provider
+ }
+
+ // Try to extract env var name (even if resolution fails)
+ if envVar, err2 := prisma.GetEnvVarName(cwd); err2 == nil {
+ w.envVarName = envVar
+ }
+
+ // Categorize error message for better user understanding
+ errMsg := err.Error()
+ if strings.Contains(errMsg, "not found") {
+ w.dbError = w.tr.WorkspaceErrorSchemaNotFound
+ w.dbConfigError = true
+ } else if strings.Contains(errMsg, "incomplete") {
+ // Store plain text, styling will be applied in buildDatabaseLines()
+ if w.envVarName != "" {
+ w.dbError = w.envVarName + w.tr.WorkspaceNotConfiguredSuffix
+ } else {
+ w.dbError = w.tr.WorkspaceDatabaseURLNotConfigured
+ }
+ w.dbConfigError = true
+ } else {
+ w.dbError = errMsg
+ }
+ return
+ }
+
+ // Store provider, URLs, and metadata
+ w.dbProvider = ds.Provider
+ w.unmaskedURL = ds.URL
+ w.maskedURL = prisma.MaskPassword(ds.URL)
+ w.envVarName = ds.EnvVarName
+ w.isHardcoded = ds.IsHardcoded
+
+ // Try to connect to database
+ if ds.URL == "" {
+ if w.envVarName != "" {
+ w.dbError = w.envVarName + w.tr.WorkspaceNotConfiguredSuffix
+ } else {
+ w.dbError = w.tr.WorkspaceNoDatabaseURL
+ }
+ w.dbConfigError = true
+ return
+ }
+
+ // Attempt connection
+ client, err := database.NewClientFromDSN(ds.Provider, ds.URL)
+ if err != nil {
+ w.dbError = err.Error()
+ return
+ }
+ defer client.Close()
+
+ // Test connection with ping
+ if err := client.Ping(); err != nil {
+ w.dbError = err.Error()
+ return
+ }
+
+ // Connection successful
+ w.dbConnected = true
+}
+
+func (w *WorkspaceContext) buildDatabaseLines() []string {
+ var lines []string
+
+ // Display provider with status on the same line
+ providerName := database.GetProviderDisplayName(w.dbProvider)
+ providerName = style.YellowBold(providerName)
+
+ // Build provider line with status
+ var providerLine string
+ if w.dbConnected {
+ statusStyled := style.GreenBold(w.tr.WorkspaceConnected)
+ providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled)
+ } else if w.dbError != "" {
+ if w.isConfigurationError() {
+ statusStyled := style.RedBold(w.tr.WorkspaceNotConfigured)
+ providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled)
+ } else {
+ statusStyled := style.RedBold(w.tr.WorkspaceDisconnected)
+ providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled)
+ }
+ } else {
+ statusStyled := style.RedBold(w.tr.WorkspaceDisconnected)
+ providerLine = fmt.Sprintf(w.tr.WorkspaceProviderLine, providerName, statusStyled)
+ }
+ lines = append(lines, providerLine)
+
+ // Display URL (always show if available)
+ if w.unmaskedURL != "" {
+ displayURL := w.maskedURL
+ if !w.showMasked {
+ displayURL = w.unmaskedURL
+ }
+
+ // Add hardcoded warning if applicable
+ if w.isHardcoded {
+ lines = append(lines, fmt.Sprintf("%s %s", displayURL, style.Red(w.tr.WorkspaceHardcodedIndicator)))
+ } else {
+ lines = append(lines, displayURL)
+ }
+ } else if w.dbError != "" && w.isConfigurationError() {
+ // Only show error in URL field if it's a configuration issue
+ // Apply styling: bold+red env var name, red "not configured"
+ if w.envVarName != "" && strings.Contains(w.dbError, w.tr.WorkspaceNotConfiguredSuffix) {
+ styledError := style.RedBold(w.envVarName) + style.Red(w.tr.WorkspaceNotConfiguredSuffix)
+ lines = append(lines, styledError)
+ } else {
+ lines = append(lines, style.Red(w.dbError))
+ }
+ } else {
+ lines = append(lines, w.tr.WorkspaceNotSet)
+ }
+
+ // Show detailed error message if disconnected (not configuration error)
+ if !w.dbConnected && w.dbError != "" && !w.isConfigurationError() {
+ lines = append(lines, style.Red(fmt.Sprintf(w.tr.WorkspaceErrorFormat, w.dbError)))
+ }
+
+ return lines
+}
+
+// isConfigurationError checks if the error is a configuration issue
+func (w *WorkspaceContext) isConfigurationError() bool {
+ return w.dbConfigError
+}
diff --git a/pkg/gui/style/style.go b/pkg/gui/style/style.go
new file mode 100644
index 0000000..85f0999
--- /dev/null
+++ b/pkg/gui/style/style.go
@@ -0,0 +1,108 @@
+package style
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Stylize applies combined ANSI styling (foreground colour code + bold flag).
+// fgCode is a raw ANSI colour code such as "31" (red) or "38;5;208" (orange).
+// If both fgCode and bold are empty/false the original text is returned unchanged.
+func Stylize(text string, fgCode string, bold bool) string {
+ if text == "" {
+ return text
+ }
+ codes := make([]string, 0, 2)
+ if fgCode != "" {
+ codes = append(codes, fgCode)
+ }
+ if bold {
+ codes = append(codes, "1")
+ }
+ if len(codes) == 0 {
+ return text
+ }
+ return fmt.Sprintf("\x1b[%sm%s\x1b[0m", strings.Join(codes, ";"), text)
+}
+
+// ---------------------------------------------------------------------------
+// Single-colour helpers
+// ---------------------------------------------------------------------------
+
+// Red colours text red (ANSI 31).
+func Red(text string) string {
+ return Stylize(text, "31", false)
+}
+
+// Green colours text green (ANSI 32).
+func Green(text string) string {
+ return Stylize(text, "32", false)
+}
+
+// Yellow colours text yellow (ANSI 33).
+func Yellow(text string) string {
+ return Stylize(text, "33", false)
+}
+
+// Blue colours text blue (ANSI 34).
+func Blue(text string) string {
+ return Stylize(text, "34", false)
+}
+
+// Magenta colours text magenta (ANSI 35).
+func Magenta(text string) string {
+ return Stylize(text, "35", false)
+}
+
+// Cyan colours text cyan (ANSI 36).
+func Cyan(text string) string {
+ return Stylize(text, "36", false)
+}
+
+// Orange colours text orange (256-colour ANSI 208).
+func Orange(text string) string {
+ return Stylize(text, "38;5;208", false)
+}
+
+// Gray colours text gray (256-colour ANSI 240).
+func Gray(text string) string {
+ return Stylize(text, "38;5;240", false)
+}
+
+// ---------------------------------------------------------------------------
+// Compound helpers (colour + bold)
+// ---------------------------------------------------------------------------
+
+// RedBold colours text red and makes it bold.
+func RedBold(text string) string {
+ return Stylize(text, "31", true)
+}
+
+// GreenBold colours text green and makes it bold.
+func GreenBold(text string) string {
+ return Stylize(text, "32", true)
+}
+
+// YellowBold colours text yellow and makes it bold.
+func YellowBold(text string) string {
+ return Stylize(text, "33", true)
+}
+
+// CyanBold colours text cyan and makes it bold.
+func CyanBold(text string) string {
+ return Stylize(text, "36", true)
+}
+
+// OrangeBold colours text orange and makes it bold.
+func OrangeBold(text string) string {
+ return Stylize(text, "38;5;208", true)
+}
+
+// ---------------------------------------------------------------------------
+// Attribute-only helpers
+// ---------------------------------------------------------------------------
+
+// Bold makes text bold (ANSI 1).
+func Bold(text string) string {
+ return Stylize(text, "", true)
+}
diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go
new file mode 100644
index 0000000..e74dbb5
--- /dev/null
+++ b/pkg/gui/types/common.go
@@ -0,0 +1,63 @@
+package types
+
+import (
+ "github.com/dokadev/lazyprisma/pkg/i18n"
+)
+
+// ConfirmOpts configures a confirmation popup.
+type ConfirmOpts struct {
+ Title string
+ Prompt string
+ HandleConfirm func() error
+ HandleClose func() error
+}
+
+// PromptOpts configures a text-input popup.
+type PromptOpts struct {
+ Title string
+ InitialContent string
+ HandleConfirm func(string) error
+}
+
+// MenuItem is a single entry in a menu popup.
+type MenuItem struct {
+ Label string
+ OnPress func() error
+ Description string
+}
+
+// MenuOpts configures a menu popup.
+type MenuOpts struct {
+ Title string
+ Items []*MenuItem
+}
+
+// IPopupHandler provides methods for displaying popups to the user.
+type IPopupHandler interface {
+ // Alert shows a simple notification popup.
+ Alert(title string, message string)
+ // Confirm shows a yes/no confirmation popup.
+ Confirm(opts ConfirmOpts)
+ // Prompt shows a text-input popup.
+ Prompt(opts PromptOpts)
+ // Menu shows a list of selectable options.
+ Menu(opts MenuOpts) error
+ // Toast shows a brief, non-blocking message.
+ Toast(message string)
+ // ErrorHandler is the global error handler for gocui.
+ ErrorHandler(err error) error
+}
+
+// IGuiCommon is the common interface available to controllers via dependency injection.
+type IGuiCommon interface {
+ IPopupHandler
+
+ // LogAction logs a user-visible action to the output panel.
+ LogAction(action string)
+ // Refresh triggers a data refresh and re-render of all contexts.
+ Refresh()
+ // OnUIThread schedules a function to run on the UI thread.
+ OnUIThread(f func() error)
+ // GetTranslationSet returns the current translation set.
+ GetTranslationSet() *i18n.TranslationSet
+}
diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go
new file mode 100644
index 0000000..e698690
--- /dev/null
+++ b/pkg/gui/types/context.go
@@ -0,0 +1,78 @@
+package types
+
+import (
+ "github.com/jesseduffield/gocui"
+)
+
+// ContextKey uniquely identifies a context.
+type ContextKey string
+
+// ContextKind categorises contexts by their role in the layout.
+type ContextKind int
+
+const (
+ // SIDE_CONTEXT is a panel on the left-hand side (workspace, migrations).
+ SIDE_CONTEXT ContextKind = iota
+ // MAIN_CONTEXT is the main content area (details, output).
+ MAIN_CONTEXT
+ // TEMPORARY_POPUP is a transient popup (confirm, prompt, menu, message).
+ TEMPORARY_POPUP
+)
+
+// OnFocusOpts carries information when a context gains focus.
+type OnFocusOpts struct {
+ ClickedViewLineIdx int
+}
+
+// OnFocusLostOpts carries information when a context loses focus.
+type OnFocusLostOpts struct {
+ NewContextKey ContextKey
+}
+
+// IBaseContext defines the minimal identity and metadata for a context.
+type IBaseContext interface {
+ GetKey() ContextKey
+ GetKind() ContextKind
+ GetViewName() string
+ GetView() *gocui.View
+ IsFocusable() bool
+ Title() string
+}
+
+// Context extends IBaseContext with lifecycle hooks.
+type Context interface {
+ IBaseContext
+
+ HandleFocus(opts OnFocusOpts)
+ HandleFocusLost(opts OnFocusLostOpts)
+ HandleRender()
+}
+
+// IListContext is a context that presents a selectable list of items.
+type IListContext interface {
+ Context
+
+ GetSelectedIdx() int
+ GetItemCount() int
+ SelectNext()
+ SelectPrev()
+}
+
+// ITabbedContext is a context that supports tabbed sub-views.
+type ITabbedContext interface {
+ Context
+
+ NextTab()
+ PrevTab()
+ GetCurrentTab() string
+}
+
+// IScrollableContext is a context that supports vertical scrolling.
+type IScrollableContext interface {
+ Context
+
+ ScrollUp()
+ ScrollDown()
+ ScrollToTop()
+ ScrollToBottom()
+}
diff --git a/pkg/gui/types/keybindings.go b/pkg/gui/types/keybindings.go
new file mode 100644
index 0000000..91f4d05
--- /dev/null
+++ b/pkg/gui/types/keybindings.go
@@ -0,0 +1,28 @@
+package types
+
+import (
+ "github.com/jesseduffield/gocui"
+)
+
+// Key is an alias for any type that can represent a key (gocui.Key or rune).
+type Key = any
+
+// Binding maps a key press to a handler within a specific context.
+type Binding struct {
+ Key Key
+ Modifier gocui.Modifier
+ Handler func() error
+ Description string
+ Tag string // e.g. "navigation", used for grouping in help views
+}
+
+// KeybindingsFn is a function that returns a slice of key bindings.
+type KeybindingsFn func() []*Binding
+
+// IController is the interface that all controllers must implement.
+// Each controller is associated with exactly one context and provides
+// the keybindings for that context.
+type IController interface {
+ GetKeybindings() []*Binding
+ Context() Context
+}
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
new file mode 100644
index 0000000..fe51d65
--- /dev/null
+++ b/pkg/i18n/english.go
@@ -0,0 +1,589 @@
+package i18n
+
+type TranslationSet struct {
+ // Panel Titles
+ PanelTitleOutput string
+ PanelTitleWorkspace string
+ PanelTitleDetails string
+
+ // Tab Labels
+ TabLocal string
+ TabPending string
+ TabDBOnly string
+ TabDetails string
+ TabActionNeeded string
+
+ // Error Messages (general)
+ ErrorFailedGetWorkingDirectory string
+ ErrorLoadingLocalMigrations string
+ ErrorNoMigrationsFound string
+ ErrorFailedAccessMigrationsPanel string
+ ErrorNoDBConnectionDetected string
+ ErrorEnsureDBAccessible string
+ ErrorFailedGetWorkingDir string
+ ErrorCannotExecuteCommand string
+ ErrorCommandCurrentlyRunning string
+ ErrorOperationBlocked string
+
+ // Modal Titles
+ ModalTitleError string
+ ModalTitleDBConnectionRequired string
+ ModalTitleMigrationError string
+ ModalTitleMigrationCreated string
+ ModalTitleMigrationFailed string
+ ModalTitleMigrateDeploySuccess string
+ ModalTitleMigrateDeployFailed string
+ ModalTitleMigrateDeployError string
+ ModalTitleGenerateSuccess string
+ ModalTitleGenerateFailed string
+ ModalTitleGenerateError string
+ ModalTitleSchemaValidationFailed string
+ ModalTitleNoMigrationSelected string
+ ModalTitleCannotResolveMigration string
+ ModalTitleMigrateResolveSuccess string
+ ModalTitleMigrateResolveFailed string
+ ModalTitleMigrateResolveError string
+ ModalTitleStudioError string
+ ModalTitleStudioStopped string
+ ModalTitleStudioStarted string
+ ModalTitleNoSelection string
+ ModalTitleCannotDelete string
+ ModalTitleDeleteError string
+ ModalTitleDeleted string
+ ModalTitleClipboardError string
+ ModalTitleCopied string
+ ModalTitlePendingMigrationsDetected string
+ ModalTitleDBOnlyMigrationsDetected string
+ ModalTitleChecksumMismatchDetected string
+ ModalTitleEmptyPendingDetected string
+ ModalTitleOperationBlocked string
+ ModalTitleDeleteMigration string
+ ModalTitleValidationFailed string
+ ModalTitleMigrateDev string
+ ModalTitleResolveMigration string
+ ModalTitleCopyToClipboard string
+ ModalTitleEnterMigrationName string
+
+ // Modal Messages
+ ModalMsgMigrationCreatedSuccess string
+ ModalMsgMigrationCreatedDetail string
+ ModalMsgMigrationFailedWithCode string
+ ModalMsgCheckOutputPanel string
+ ModalMsgMigrationsAppliedSuccess string
+ ModalMsgMigrateDeployFailedWithCode string
+ ModalMsgFailedRunMigrateDeploy string
+ ModalMsgFailedStartMigrateDeploy string
+ ModalMsgPrismaClientGenerated string
+ ModalMsgGenerateFailedSchemaErrors string
+ ModalMsgGenerateFailedWithCode string
+ ModalMsgSchemaValidCheckOutput string
+ ModalMsgFailedRunGenerate string
+ ModalMsgFailedStartGenerate string
+ ModalMsgSelectMigrationResolve string
+ ModalMsgOnlyInTransactionResolve string
+ ModalMsgMigrationNotFailed string
+ ModalMsgMigrationMarkedSuccess string
+ ModalMsgMigrateResolveFailedWithCode string
+ ModalMsgFailedRunMigrateResolve string
+ ModalMsgFailedStartMigrateResolve string
+ ModalMsgFailedStopStudio string
+ ModalMsgStudioStopped string
+ ModalMsgFailedStartStudio string
+ ModalMsgStudioRunningAt string
+ ModalMsgPressStopStudio string
+ ModalMsgSelectMigrationDelete string
+ ModalMsgMigrationDBOnly string
+ ModalMsgCannotDeleteNoLocalFile string
+ ModalMsgMigrationAlreadyApplied string
+ ModalMsgDeleteLocalInconsistency string
+ ModalMsgFailedCreateFolder string
+ ModalMsgFailedDeleteFolder string
+ ModalMsgFailedWriteMigrationFile string
+ ModalMsgMigrationDeletedSuccess string
+ ModalMsgFailedCopyClipboard string
+ ModalMsgCopiedToClipboard string
+ ModalMsgPendingMigrationsWarning string
+ ModalMsgCannotCreateWithDBOnly string
+ ModalMsgResolveDBOnlyFirst string
+ ModalMsgCannotCreateWithMismatch string
+ ModalMsgMigrationModifiedLocally string
+ ModalMsgCannotCreateWithEmpty string
+ ModalMsgMigrationPendingEmpty string
+ ModalMsgDeleteOrAddContent string
+ ModalMsgAnotherOperationRunning string
+ ModalMsgWaitComplete string
+ ModalMsgConfirmDeleteMigration string
+ ModalMsgSpacesReplaced string
+ ModalMsgInputRequired string
+ ModalMsgManualMigrationCreated string
+ ModalMsgManualMigrationLocation string
+ CopyLabelMigrationName string
+ CopyLabelMigrationPath string
+ CopyLabelChecksum string
+
+ // Modal Footers
+ ModalFooterInputSubmitCancel string
+ ModalFooterListNavigate string
+ ModalFooterMessageClose string
+ ModalFooterConfirmYesNo string
+
+ // Status Bar
+ StatusStudioOn string
+ KeyHintRefresh string
+ KeyHintDev string
+ KeyHintDeploy string
+ KeyHintGenerate string
+ KeyHintResolve string
+ KeyHintStudio string
+ KeyHintCopy string
+
+ // Action Labels
+ ActionLabelApplied string
+ ActionLabelRolledBack string
+
+ // Log Actions
+ LogActionMigrateDeploy string
+ LogMsgRunningMigrateDeploy string
+ LogActionMigrateDeployComplete string
+ LogMsgMigrationsAppliedSuccess string
+ LogActionMigrateDeployFailed string
+ LogMsgMigrateDeployFailedCode string
+ LogActionMigrateResolve string
+ LogMsgMarkingMigration string
+ LogActionMigrateResolveComplete string
+ LogMsgMigrationMarked string
+ LogActionMigrateResolveFailed string
+ LogMsgMigrateResolveFailedCode string
+ LogActionMigrateResolveError string
+ LogActionGenerate string
+ LogMsgRunningGenerate string
+ LogActionGenerateComplete string
+ LogMsgPrismaClientGeneratedSuccess string
+ LogActionGenerateFailed string
+ LogMsgCheckingSchemaErrors string
+ LogActionSchemaValidationFailed string
+ LogMsgFoundSchemaErrors string
+ LogActionGenerateError string
+ LogActionStudio string
+ LogMsgStartingStudio string
+ LogActionStudioStarted string
+ LogMsgStudioListeningAt string
+ LogActionStudioStopped string
+ LogMsgStudioHasStopped string
+ LogActionMigrateDev string
+ LogMsgCreatingMigration string
+ LogActionMigrateComplete string
+ LogMsgMigrationCreatedSuccess string
+ LogActionMigrateFailed string
+ LogMsgMigrationCreationFailedCode string
+ LogActionMigrationError string
+ LogMsgFailedDeleteMigration string
+ LogActionDeleted string
+ LogMsgMigrationDeleted string
+ SuccessAllPanelsRefreshed string
+ ActionRefresh string
+
+ // List Modal Items
+ ListItemSchemaDiffMigration string
+ ListItemDescSchemaDiffMigration string
+ ListItemManualMigration string
+ ListItemDescManualMigration string
+ ListItemMarkApplied string
+ ListItemDescMarkApplied string
+ ListItemMarkRolledBack string
+ ListItemDescMarkRolledBack string
+ ListItemCopyName string
+ ListItemCopyPath string
+ ListItemCopyChecksum string
+
+ // Details Panel - Migration Status
+ MigrationStatusInTransaction string
+ MigrationStatusDBOnly string
+ MigrationStatusChecksumMismatch string
+ MigrationStatusApplied string
+ MigrationStatusEmptyMigration string
+ MigrationStatusPending string
+
+ // Details Panel - Labels & Descriptions
+ DetailsPanelInitialPlaceholder string
+ DetailsNameLabel string
+ DetailsTimestampLabel string
+ DetailsPathLabel string
+ DetailsStatusLabel string
+ DetailsAppliedAtLabel string
+ DetailsDownMigrationLabel string
+ DetailsDownMigrationAvailable string
+ DetailsDownMigrationNotAvailable string
+ DetailsStartedAtLabel string
+ DetailsInTransactionWarning string
+ DetailsNoAdditionalMigrationsWarning string
+ DetailsResolveManuallyInstruction string
+ DetailsErrorLogsLabel string
+ DetailsDBOnlyDescription string
+ DetailsChecksumModifiedDescription string
+ DetailsChecksumIssuesWarning string
+ DetailsLocalChecksumLabel string
+ DetailsHistoryChecksumLabel string
+ DetailsEmptyMigrationDescription string
+ DetailsEmptyMigrationWarning string
+ DetailsDownMigrationSQLLabel string
+ ErrorReadingMigrationSQL string
+
+ // Details Panel - Action Needed
+ ActionNeededNoIssuesMessage string
+ ActionNeededHeader string
+ ActionNeededIssueSingular string
+ ActionNeededIssuePlural string
+ ActionNeededEmptyMigrationsHeader string
+ ActionNeededEmptyDescription string
+ ActionNeededAffectedLabel string
+ ActionNeededRecommendedLabel string
+ ActionNeededAddMigrationSQL string
+ ActionNeededDeleteEmptyFolders string
+ ActionNeededMarkAsBaseline string
+ ActionNeededChecksumMismatchHeader string
+ ActionNeededChecksumModifiedDescription string
+ ActionNeededWarningPrefix string
+ ActionNeededEditingInconsistenciesWarning string
+ ActionNeededRevertLocalChanges string
+ ActionNeededCreateNewInstead string
+ ActionNeededContactTeamIfNeeded string
+ ActionNeededSchemaValidationErrorsHeader string
+ ActionNeededSchemaValidationFailedDesc string
+ ActionNeededFixBeforeMigration string
+ ActionNeededValidationOutputLabel string
+ ActionNeededRecommendedActionsLabel string
+ ActionNeededFixSchemaErrors string
+ ActionNeededCheckLineNumbers string
+ ActionNeededReferPrismaDocumentation string
+
+ // Workspace Panel
+ WorkspaceVersionLine string
+ WorkspacePrismaGlobalIndicator string
+ WorkspaceGitLine string
+ WorkspaceSchemaModifiedIndicator string
+ WorkspaceBranchFormat string
+ WorkspaceNotGitRepository string
+ WorkspaceConnected string
+ WorkspaceNotConfigured string
+ WorkspaceDisconnected string
+ WorkspaceProviderLine string
+ WorkspaceHardcodedIndicator string
+ WorkspaceNotSet string
+ WorkspaceErrorFormat string
+ WorkspaceErrorGetWorkingDirectory string
+ WorkspaceErrorSchemaNotFound string
+ WorkspaceNotConfiguredSuffix string
+ WorkspaceDatabaseURLNotConfigured string
+ WorkspaceNoDatabaseURL string
+ WorkspaceVersionNotFound string
+
+ // Migrations Panel
+ MigrationsFooterFormat string
+
+ // main.go strings
+ VersionOutput string
+ ErrorFailedGetCurrentDir string
+ ErrorNotPrismaWorkspace string
+ ErrorExpectedOneOf string
+ ErrorExpectedConfigV7Plus string
+ ErrorExpectedSchemaV7Minus string
+ ErrorFailedCreateApp string
+ ErrorFailedRegisterKeybindings string
+ ErrorAppRuntime string
+}
+
+func EnglishTranslationSet() *TranslationSet {
+ return &TranslationSet{
+ // Panel Titles
+ PanelTitleOutput: "Output",
+ PanelTitleWorkspace: "Workspace",
+ PanelTitleDetails: "Details",
+
+ // Tab Labels
+ TabLocal: "Local",
+ TabPending: "Pending",
+ TabDBOnly: "DB-Only",
+ TabDetails: "Details",
+ TabActionNeeded: "Action-Needed",
+
+ // Error Messages (general)
+ ErrorFailedGetWorkingDirectory: "Error: Failed to get working directory",
+ ErrorLoadingLocalMigrations: "Error loading local migrations: %v",
+ ErrorNoMigrationsFound: "No migrations found",
+ ErrorFailedAccessMigrationsPanel: "Failed to access migrations panel.",
+ ErrorNoDBConnectionDetected: "No database connection detected.",
+ ErrorEnsureDBAccessible: "Please ensure your database is running and accessible.",
+ ErrorFailedGetWorkingDir: "Failed to get working directory:",
+ ErrorCannotExecuteCommand: "Cannot execute '%s'",
+ ErrorCommandCurrentlyRunning: " — '%s' is currently running",
+ ErrorOperationBlocked: "Operation Blocked",
+
+ // Modal Titles
+ ModalTitleError: "Error",
+ ModalTitleDBConnectionRequired: "Database Connection Required",
+ ModalTitleMigrationError: "Migration Error",
+ ModalTitleMigrationCreated: "Migration Created",
+ ModalTitleMigrationFailed: "Migration Failed",
+ ModalTitleMigrateDeploySuccess: "Migrate Deploy Successful",
+ ModalTitleMigrateDeployFailed: "Migrate Deploy Failed",
+ ModalTitleMigrateDeployError: "Migrate Deploy Error",
+ ModalTitleGenerateSuccess: "Generate Successful",
+ ModalTitleGenerateFailed: "Generate Failed",
+ ModalTitleGenerateError: "Generate Error",
+ ModalTitleSchemaValidationFailed: "Schema Validation Failed",
+ ModalTitleNoMigrationSelected: "No Migration Selected",
+ ModalTitleCannotResolveMigration: "Cannot Resolve Migration",
+ ModalTitleMigrateResolveSuccess: "Migrate Resolve Successful",
+ ModalTitleMigrateResolveFailed: "Migrate Resolve Failed",
+ ModalTitleMigrateResolveError: "Migrate Resolve Error",
+ ModalTitleStudioError: "Studio Error",
+ ModalTitleStudioStopped: "Studio Stopped",
+ ModalTitleStudioStarted: "Prisma Studio Started",
+ ModalTitleNoSelection: "No Selection",
+ ModalTitleCannotDelete: "Cannot Delete",
+ ModalTitleDeleteError: "Delete Error",
+ ModalTitleDeleted: "Deleted",
+ ModalTitleClipboardError: "Clipboard Error",
+ ModalTitleCopied: "Copied",
+ ModalTitlePendingMigrationsDetected: "Pending Migrations Detected",
+ ModalTitleDBOnlyMigrationsDetected: "DB-Only Migrations Detected",
+ ModalTitleChecksumMismatchDetected: "Checksum Mismatch Detected",
+ ModalTitleEmptyPendingDetected: "Empty Pending Migration Detected",
+ ModalTitleOperationBlocked: "Operation Blocked",
+ ModalTitleDeleteMigration: "Delete Migration",
+ ModalTitleValidationFailed: "Validation Failed",
+ ModalTitleMigrateDev: "Migrate Dev",
+ ModalTitleResolveMigration: "Resolve Migration: %s",
+ ModalTitleCopyToClipboard: "Copy to Clipboard",
+ ModalTitleEnterMigrationName: "Enter migration name",
+
+ // Modal Messages
+ ModalMsgMigrationCreatedSuccess: "Migration '%s' created successfully!",
+ ModalMsgMigrationCreatedDetail: "You can find it in the prisma/migrations directory.",
+ ModalMsgMigrationFailedWithCode: "Prisma migrate dev failed with exit code: %d",
+ ModalMsgCheckOutputPanel: "Check output panel for details.",
+ ModalMsgMigrationsAppliedSuccess: "Migrations applied successfully!",
+ ModalMsgMigrateDeployFailedWithCode: "Prisma migrate deploy failed with exit code: %d",
+ ModalMsgFailedRunMigrateDeploy: "Failed to run prisma migrate deploy:",
+ ModalMsgFailedStartMigrateDeploy: "Failed to start migrate deploy:",
+ ModalMsgPrismaClientGenerated: "Prisma Client generated successfully!",
+ ModalMsgGenerateFailedSchemaErrors: "Generate failed due to schema errors.",
+ ModalMsgGenerateFailedWithCode: "Prisma generate failed with exit code: %d",
+ ModalMsgSchemaValidCheckOutput: "Schema is valid. Check output panel for details.",
+ ModalMsgFailedRunGenerate: "Failed to run prisma generate:",
+ ModalMsgFailedStartGenerate: "Failed to start generate:",
+ ModalMsgSelectMigrationResolve: "Please select a migration to resolve.",
+ ModalMsgOnlyInTransactionResolve: "Only migrations in 'In-Transaction' state can be resolved.",
+ ModalMsgMigrationNotFailed: "Migration '%s' is not in a failed state.",
+ ModalMsgMigrationMarkedSuccess: "Migration marked as %s successfully!",
+ ModalMsgMigrateResolveFailedWithCode: "Prisma migrate resolve failed with exit code: %d",
+ ModalMsgFailedRunMigrateResolve: "Failed to run prisma migrate resolve:",
+ ModalMsgFailedStartMigrateResolve: "Failed to start migrate resolve:",
+ ModalMsgFailedStopStudio: "Failed to stop Prisma Studio:",
+ ModalMsgStudioStopped: "Prisma Studio has been stopped.",
+ ModalMsgFailedStartStudio: "Failed to start Prisma Studio:",
+ ModalMsgStudioRunningAt: "Prisma Studio is running at http://localhost:5555",
+ ModalMsgPressStopStudio: "Press 'S' again to stop it.",
+ ModalMsgSelectMigrationDelete: "Please select a migration to delete.",
+ ModalMsgMigrationDBOnly: "This migration exists only in the database (DB-Only).",
+ ModalMsgCannotDeleteNoLocalFile: "Cannot delete a migration that has no local file.",
+ ModalMsgMigrationAlreadyApplied: "This migration has already been applied to the database.",
+ ModalMsgDeleteLocalInconsistency: "Deleting it locally will cause inconsistency.",
+ ModalMsgFailedCreateFolder: "Failed to create migration folder:",
+ ModalMsgFailedDeleteFolder: "Failed to delete migration folder:",
+ ModalMsgFailedWriteMigrationFile: "Failed to write migration file:",
+ ModalMsgMigrationDeletedSuccess: "Migration deleted successfully.",
+ ModalMsgFailedCopyClipboard: "Failed to copy to clipboard:",
+ ModalMsgCopiedToClipboard: "%s copied to clipboard!",
+ ModalMsgPendingMigrationsWarning: "Prisma automatically applies pending migrations before creating new ones. This may cause unintended behaviour in the future. Do you wish to continue?",
+ ModalMsgCannotCreateWithDBOnly: "Cannot create new migration whilst DB-Only migrations exist.",
+ ModalMsgResolveDBOnlyFirst: "Please resolve DB-Only migrations first.",
+ ModalMsgCannotCreateWithMismatch: "Cannot create new migration whilst checksum mismatch exists.",
+ ModalMsgMigrationModifiedLocally: "Migration '%s' has been modified locally.",
+ ModalMsgCannotCreateWithEmpty: "Cannot create new migration whilst empty pending migrations exist.",
+ ModalMsgMigrationPendingEmpty: "Migration '%s' is pending and empty.",
+ ModalMsgDeleteOrAddContent: "Please delete it or add SQL content.",
+ ModalMsgAnotherOperationRunning: "Another operation is currently running.",
+ ModalMsgWaitComplete: "Please wait for it to complete.",
+ ModalMsgConfirmDeleteMigration: "Are you sure you want to delete this migration?\n\n%s\n\nThis action cannot be undone.",
+ ModalMsgSpacesReplaced: "Spaces will be replaced with underscores",
+ ModalMsgInputRequired: "Input is required",
+ ModalMsgManualMigrationCreated: "Created: %s",
+ ModalMsgManualMigrationLocation: "Location: %s",
+ CopyLabelMigrationName: "Migration Name",
+ CopyLabelMigrationPath: "Migration Path",
+ CopyLabelChecksum: "Checksum",
+
+ // Modal Footers
+ ModalFooterInputSubmitCancel: "[Enter] Submit [ESC] Cancel",
+ ModalFooterListNavigate: "[↑/↓] Navigate [Enter] Select [ESC] Cancel",
+ ModalFooterMessageClose: " [Enter/q/ESC] Close ",
+ ModalFooterConfirmYesNo: " [Y] Yes [N] No [ESC] Cancel ",
+
+ // Status Bar
+ StatusStudioOn: "[Studio: ON]",
+ KeyHintRefresh: "efresh",
+ KeyHintDev: "ev",
+ KeyHintDeploy: "eploy",
+ KeyHintGenerate: "enerate",
+ KeyHintResolve: "resolve",
+ KeyHintStudio: "tudio",
+ KeyHintCopy: "opy",
+
+ // Action Labels
+ ActionLabelApplied: "applied",
+ ActionLabelRolledBack: "rolled back",
+
+ // Log Actions
+ LogActionMigrateDeploy: "Migrate Deploy",
+ LogMsgRunningMigrateDeploy: "Running prisma migrate deploy...",
+ LogActionMigrateDeployComplete: "Migrate Deploy Complete",
+ LogMsgMigrationsAppliedSuccess: "Migrations applied successfully",
+ LogActionMigrateDeployFailed: "Migrate Deploy Failed",
+ LogMsgMigrateDeployFailedCode: "Migrate deploy failed with exit code: %d",
+ LogActionMigrateResolve: "Migrate Resolve",
+ LogMsgMarkingMigration: "Marking migration as %s: %s",
+ LogActionMigrateResolveComplete: "Migrate Resolve Complete",
+ LogMsgMigrationMarked: "Migration marked as %s successfully",
+ LogActionMigrateResolveFailed: "Migrate Resolve Failed",
+ LogMsgMigrateResolveFailedCode: "Migrate resolve failed with exit code: %d",
+ LogActionMigrateResolveError: "Migrate Resolve Error",
+ LogActionGenerate: "Generate",
+ LogMsgRunningGenerate: "Running prisma generate...",
+ LogActionGenerateComplete: "Generate Complete",
+ LogMsgPrismaClientGeneratedSuccess: "Prisma Client generated successfully",
+ LogActionGenerateFailed: "Generate Failed",
+ LogMsgCheckingSchemaErrors: "Checking schema for errors...",
+ LogActionSchemaValidationFailed: "Schema Validation Failed",
+ LogMsgFoundSchemaErrors: "Found %d schema errors",
+ LogActionGenerateError: "Generate Error",
+ LogActionStudio: "Studio",
+ LogMsgStartingStudio: "Starting Prisma Studio...",
+ LogActionStudioStarted: "Studio Started",
+ LogMsgStudioListeningAt: "Prisma Studio is running at http://localhost:5555",
+ LogActionStudioStopped: "Studio Stopped",
+ LogMsgStudioHasStopped: "Prisma Studio has been stopped",
+ LogActionMigrateDev: "Migrate Dev",
+ LogMsgCreatingMigration: "Creating migration: %s",
+ LogActionMigrateComplete: "Migrate Complete",
+ LogMsgMigrationCreatedSuccess: "Migration created successfully",
+ LogActionMigrateFailed: "Migrate Failed",
+ LogMsgMigrationCreationFailedCode: "Migration creation failed with exit code: %d",
+ LogActionMigrationError: "Migration Error",
+ LogMsgFailedDeleteMigration: "Failed to delete migration: %s",
+ LogActionDeleted: "Deleted",
+ LogMsgMigrationDeleted: "Migration '%s' deleted",
+ SuccessAllPanelsRefreshed: "All panels have been refreshed",
+ ActionRefresh: "Refresh",
+
+ // List Modal Items
+ ListItemSchemaDiffMigration: "Schema diff-based migration",
+ ListItemDescSchemaDiffMigration: "Create a migration from changes in Prisma schema, apply it to the database, trigger generators (e.g. Prisma Client)",
+ ListItemManualMigration: "Manual migration",
+ ListItemDescManualMigration: "This tool creates manual migrations for database changes that cannot be expressed through Prisma schema diff. It is used to explicitly record and version control database-specific logic such as triggers, functions, and DML operations that cannot be managed at the Prisma schema level.",
+ ListItemMarkApplied: "Mark as applied",
+ ListItemDescMarkApplied: "Mark this migration as successfully applied to the database. Use this if you have manually fixed the issue and the migration changes are now present in the database.",
+ ListItemMarkRolledBack: "Mark as rolled back",
+ ListItemDescMarkRolledBack: "Mark this migration as rolled back (reverted from the database). Use this if you have manually reverted the changes and the migration is no longer applied to the database.",
+ ListItemCopyName: "Copy Name",
+ ListItemCopyPath: "Copy Path",
+ ListItemCopyChecksum: "Copy Checksum",
+
+ // Details Panel - Migration Status
+ MigrationStatusInTransaction: "⚠ In-Transaction",
+ MigrationStatusDBOnly: "✗ DB Only",
+ MigrationStatusChecksumMismatch: "⚠ Checksum Mismatch",
+ MigrationStatusApplied: "✓ Applied",
+ MigrationStatusEmptyMigration: "⚠ Empty Migration",
+ MigrationStatusPending: "⚠ Pending",
+
+ // Details Panel - Labels & Descriptions
+ DetailsPanelInitialPlaceholder: "Details\n\nSelect a migration to view details...",
+ DetailsNameLabel: "Name: %s\n",
+ DetailsTimestampLabel: "Timestamp: %s\n",
+ DetailsPathLabel: "Path: %s\n",
+ DetailsStatusLabel: "Status: ",
+ DetailsAppliedAtLabel: "Applied at: %s",
+ DetailsDownMigrationLabel: "Down Migration: ",
+ DetailsDownMigrationAvailable: "✓ Available",
+ DetailsDownMigrationNotAvailable: "✗ Not available",
+ DetailsStartedAtLabel: "Started At: ",
+ DetailsInTransactionWarning: "⚠ WARNING: This migration is stuck in an incomplete state.",
+ DetailsNoAdditionalMigrationsWarning: "No additional migrations can be applied until this is resolved.",
+ DetailsResolveManuallyInstruction: "Please resolve this migration manually before proceeding.\n",
+ DetailsErrorLogsLabel: "Error Logs:",
+ DetailsDBOnlyDescription: "This migration exists in the database but not in local files.",
+ DetailsChecksumModifiedDescription: "The local migration file has been modified after being applied to the database.\n",
+ DetailsChecksumIssuesWarning: "This can cause issues during deployment.\n\n",
+ DetailsLocalChecksumLabel: "Local Checksum: ",
+ DetailsHistoryChecksumLabel: "History Checksum: ",
+ DetailsEmptyMigrationDescription: "This migration folder is empty or missing migration.sql.\n",
+ DetailsEmptyMigrationWarning: "This may cause issues during deployment.",
+ DetailsDownMigrationSQLLabel: "Down Migration SQL:",
+ ErrorReadingMigrationSQL: "Error reading migration.sql:\n%v",
+
+ // Details Panel - Action Needed
+ ActionNeededNoIssuesMessage: "No action required\n\nAll migrations are in good state and schema is valid.",
+ ActionNeededHeader: "⚠ Action Needed",
+ ActionNeededIssueSingular: " issue",
+ ActionNeededIssuePlural: "s",
+ ActionNeededEmptyMigrationsHeader: "Empty Migrations",
+ ActionNeededEmptyDescription: "These migrations have no SQL content.\n\n",
+ ActionNeededAffectedLabel: "Affected:\n",
+ ActionNeededRecommendedLabel: "Recommended Actions:\n",
+ ActionNeededAddMigrationSQL: " → Add migration.sql manually\n",
+ ActionNeededDeleteEmptyFolders: " → Delete empty migration folders\n",
+ ActionNeededMarkAsBaseline: " → Mark as baseline migration\n\n",
+ ActionNeededChecksumMismatchHeader: "Checksum Mismatch",
+ ActionNeededChecksumModifiedDescription: "Migration content was modified after\nbeing applied to database.\n\n",
+ ActionNeededWarningPrefix: "⚠ WARNING: ",
+ ActionNeededEditingInconsistenciesWarning: "Editing applied migrations\ncan cause inconsistencies.\n\n",
+ ActionNeededRevertLocalChanges: " → Revert local changes\n",
+ ActionNeededCreateNewInstead: " → Create new migration instead\n",
+ ActionNeededContactTeamIfNeeded: " → Contact team if needed\n\n",
+ ActionNeededSchemaValidationErrorsHeader: "Schema Validation Errors",
+ ActionNeededSchemaValidationFailedDesc: "Schema validation failed.\n",
+ ActionNeededFixBeforeMigration: "Fix these issues before running migrations.\n\n",
+ ActionNeededValidationOutputLabel: "Validation Output:",
+ ActionNeededRecommendedActionsLabel: "Recommended Actions:",
+ ActionNeededFixSchemaErrors: " → Fix schema.prisma errors\n",
+ ActionNeededCheckLineNumbers: " → Check line numbers in output above\n",
+ ActionNeededReferPrismaDocumentation: " → Refer to Prisma documentation\n",
+
+ // Workspace Panel
+ WorkspaceVersionLine: "Node: %s | Prisma: %s",
+ WorkspacePrismaGlobalIndicator: " (Global)",
+ WorkspaceGitLine: "Git: %s",
+ WorkspaceSchemaModifiedIndicator: " (schema modified)",
+ WorkspaceBranchFormat: "(%s)",
+ WorkspaceNotGitRepository: "Git: Not a git repository",
+ WorkspaceConnected: "✓ Connected",
+ WorkspaceNotConfigured: "✗ Not configured",
+ WorkspaceDisconnected: "✗ Disconnected",
+ WorkspaceProviderLine: "Provider: %s %s",
+ WorkspaceHardcodedIndicator: " (Hard coded)",
+ WorkspaceNotSet: "Not set",
+ WorkspaceErrorFormat: "Error: %s",
+ WorkspaceErrorGetWorkingDirectory: "Error getting working directory",
+ WorkspaceErrorSchemaNotFound: "Schema file not found",
+ WorkspaceNotConfiguredSuffix: " not configured",
+ WorkspaceDatabaseURLNotConfigured: "DATABASE_URL not configured",
+ WorkspaceNoDatabaseURL: "No DATABASE_URL",
+ WorkspaceVersionNotFound: "Not found",
+
+ // Migrations Panel
+ MigrationsFooterFormat: "%d of %d",
+
+ // main.go strings
+ VersionOutput: "LazyPrisma %s (%s)\n",
+ ErrorFailedGetCurrentDir: "Error: Failed to get current directory: %v\n",
+ ErrorNotPrismaWorkspace: "Error: Current directory is not a Prisma workspace.\n",
+ ErrorExpectedOneOf: "\nExpected one of:\n",
+ ErrorExpectedConfigV7Plus: " - prisma.config.ts (Prisma v7.0+)\n",
+ ErrorExpectedSchemaV7Minus: " - prisma/schema.prisma (Prisma < v7.0)\n",
+ ErrorFailedCreateApp: "Failed to create app: %v\n",
+ ErrorFailedRegisterKeybindings: "Failed to register keybindings: %v\n",
+ ErrorAppRuntime: "App error: %v\n",
+ }
+}
diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go
new file mode 100644
index 0000000..a6349f4
--- /dev/null
+++ b/pkg/i18n/i18n.go
@@ -0,0 +1,90 @@
+package i18n
+
+import (
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "strings"
+
+ "dario.cat/mergo"
+)
+
+//go:embed translations/*.json
+var translationsFS embed.FS
+
+// NewTranslationSet returns a TranslationSet for the given language.
+// If language is "auto", it detects the system language.
+// Falls back to English if the language is not supported.
+func NewTranslationSet(language string) *TranslationSet {
+ if language == "auto" || language == "" {
+ language = detectSystemLanguage()
+ }
+
+ if language == "en" {
+ return EnglishTranslationSet()
+ }
+
+ base := EnglishTranslationSet()
+ overlay, err := loadLanguageJSON(language)
+ if err != nil {
+ fmt.Printf("warning: failed to load translations for %q: %v\n", language, err)
+ return base
+ }
+
+ if err := mergo.Merge(base, &overlay, mergo.WithOverride); err != nil {
+ fmt.Printf("warning: failed to merge translations for %q: %v\n", language, err)
+ return EnglishTranslationSet()
+ }
+
+ return base
+}
+
+// loadLanguageJSON reads a translation JSON file from the embedded filesystem.
+// If the file does not exist, it returns an empty TranslationSet with nil error.
+func loadLanguageJSON(language string) (TranslationSet, error) {
+ filename := fmt.Sprintf("translations/%s.json", language)
+ data, err := translationsFS.ReadFile(filename)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return TranslationSet{}, nil
+ }
+ return TranslationSet{}, fmt.Errorf("reading %s: %w", filename, err)
+ }
+
+ var ts TranslationSet
+ if err := json.Unmarshal(data, &ts); err != nil {
+ return TranslationSet{}, fmt.Errorf("invalid JSON in %s: %w", filename, err)
+ }
+
+ return ts, nil
+}
+
+// detectSystemLanguage checks LANG, LC_ALL, LC_MESSAGES environment variables.
+func detectSystemLanguage() string {
+ for _, envVar := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
+ if val := os.Getenv(envVar); val != "" {
+ return parseLanguageCode(val)
+ }
+ }
+ return "en"
+}
+
+// parseLanguageCode extracts the language code from locale strings like "ko_KR.UTF-8".
+func parseLanguageCode(locale string) string {
+ // Remove encoding (e.g., ".UTF-8")
+ if idx := strings.Index(locale, "."); idx != -1 {
+ locale = locale[:idx]
+ }
+ // Remove country (e.g., "_KR")
+ if idx := strings.Index(locale, "_"); idx != -1 {
+ locale = locale[:idx]
+ }
+ // Remove region variant (e.g., "-KR")
+ if idx := strings.Index(locale, "-"); idx != -1 {
+ locale = locale[:idx]
+ }
+ return strings.ToLower(locale)
+}
diff --git a/pkg/i18n/translations/de.json b/pkg/i18n/translations/de.json
new file mode 100644
index 0000000..710b923
--- /dev/null
+++ b/pkg/i18n/translations/de.json
@@ -0,0 +1,269 @@
+{
+ "PanelTitleOutput": "Ausgabe",
+ "PanelTitleWorkspace": "Arbeitsbereich",
+ "PanelTitleDetails": "Details",
+
+ "TabLocal": "Lokal",
+ "TabPending": "Ausstehend",
+ "TabDBOnly": "Nur-DB",
+ "TabDetails": "Details",
+ "TabActionNeeded": "Handlungsbedarf",
+
+ "ErrorFailedGetWorkingDirectory": "Fehler: Arbeitsverzeichnis konnte nicht ermittelt werden",
+ "ErrorLoadingLocalMigrations": "Fehler beim Laden lokaler Migrationen: %v",
+ "ErrorNoMigrationsFound": "Keine Migrationen gefunden",
+ "ErrorFailedAccessMigrationsPanel": "Zugriff auf das Migrationen-Panel fehlgeschlagen.",
+ "ErrorNoDBConnectionDetected": "Keine Datenbankverbindung erkannt.",
+ "ErrorEnsureDBAccessible": "Bitte stellen Sie sicher, dass Ihre Datenbank läuft und erreichbar ist.",
+ "ErrorFailedGetWorkingDir": "Arbeitsverzeichnis konnte nicht ermittelt werden:",
+ "ErrorCannotExecuteCommand": "'%s' kann nicht ausgeführt werden",
+ "ErrorCommandCurrentlyRunning": " — '%s' wird derzeit ausgeführt",
+ "ErrorOperationBlocked": "Vorgang blockiert",
+
+ "ModalTitleError": "Fehler",
+ "ModalTitleDBConnectionRequired": "Datenbankverbindung erforderlich",
+ "ModalTitleMigrationError": "Migrationsfehler",
+ "ModalTitleMigrationCreated": "Migration erstellt",
+ "ModalTitleMigrationFailed": "Migration fehlgeschlagen",
+ "ModalTitleMigrateDeploySuccess": "Migrate Deploy erfolgreich",
+ "ModalTitleMigrateDeployFailed": "Migrate Deploy fehlgeschlagen",
+ "ModalTitleMigrateDeployError": "Migrate Deploy Fehler",
+ "ModalTitleGenerateSuccess": "Generierung erfolgreich",
+ "ModalTitleGenerateFailed": "Generierung fehlgeschlagen",
+ "ModalTitleGenerateError": "Generierungsfehler",
+ "ModalTitleSchemaValidationFailed": "Schema-Validierung fehlgeschlagen",
+ "ModalTitleNoMigrationSelected": "Keine Migration ausgewählt",
+ "ModalTitleCannotResolveMigration": "Migration kann nicht aufgelöst werden",
+ "ModalTitleMigrateResolveSuccess": "Migrate Resolve erfolgreich",
+ "ModalTitleMigrateResolveFailed": "Migrate Resolve fehlgeschlagen",
+ "ModalTitleMigrateResolveError": "Migrate Resolve Fehler",
+ "ModalTitleStudioError": "Studio-Fehler",
+ "ModalTitleStudioStopped": "Studio gestoppt",
+ "ModalTitleStudioStarted": "Prisma Studio gestartet",
+ "ModalTitleNoSelection": "Keine Auswahl",
+ "ModalTitleCannotDelete": "Löschen nicht möglich",
+ "ModalTitleDeleteError": "Löschfehler",
+ "ModalTitleDeleted": "Gelöscht",
+ "ModalTitleClipboardError": "Zwischenablagefehler",
+ "ModalTitleCopied": "Kopiert",
+ "ModalTitlePendingMigrationsDetected": "Ausstehende Migrationen erkannt",
+ "ModalTitleDBOnlyMigrationsDetected": "Nur-DB-Migrationen erkannt",
+ "ModalTitleChecksumMismatchDetected": "Prüfsummen-Abweichung erkannt",
+ "ModalTitleEmptyPendingDetected": "Leere ausstehende Migration erkannt",
+ "ModalTitleOperationBlocked": "Vorgang blockiert",
+ "ModalTitleDeleteMigration": "Migration löschen",
+ "ModalTitleValidationFailed": "Validierung fehlgeschlagen",
+ "ModalTitleMigrateDev": "Migrate Dev",
+ "ModalTitleResolveMigration": "Migration auflösen: %s",
+ "ModalTitleCopyToClipboard": "In Zwischenablage kopieren",
+ "ModalTitleEnterMigrationName": "Migrationsname eingeben",
+
+ "ModalMsgMigrationCreatedSuccess": "Migration '%s' erfolgreich erstellt!",
+ "ModalMsgMigrationCreatedDetail": "Sie finden sie im Verzeichnis prisma/migrations.",
+ "ModalMsgMigrationFailedWithCode": "prisma migrate dev mit Exit-Code fehlgeschlagen: %d",
+ "ModalMsgCheckOutputPanel": "Überprüfen Sie das Ausgabe-Panel für Details.",
+ "ModalMsgMigrationsAppliedSuccess": "Migrationen erfolgreich angewendet!",
+ "ModalMsgMigrateDeployFailedWithCode": "prisma migrate deploy mit Exit-Code fehlgeschlagen: %d",
+ "ModalMsgFailedRunMigrateDeploy": "prisma migrate deploy konnte nicht ausgeführt werden:",
+ "ModalMsgFailedStartMigrateDeploy": "Migrate Deploy konnte nicht gestartet werden:",
+ "ModalMsgPrismaClientGenerated": "Prisma Client erfolgreich generiert!",
+ "ModalMsgGenerateFailedSchemaErrors": "Generierung aufgrund von Schema-Fehlern fehlgeschlagen.",
+ "ModalMsgGenerateFailedWithCode": "prisma generate mit Exit-Code fehlgeschlagen: %d",
+ "ModalMsgSchemaValidCheckOutput": "Schema ist gültig. Überprüfen Sie das Ausgabe-Panel für Details.",
+ "ModalMsgFailedRunGenerate": "prisma generate konnte nicht ausgeführt werden:",
+ "ModalMsgFailedStartGenerate": "Generierung konnte nicht gestartet werden:",
+ "ModalMsgSelectMigrationResolve": "Bitte wählen Sie eine Migration zum Auflösen aus.",
+ "ModalMsgOnlyInTransactionResolve": "Nur Migrationen im Status 'In-Transaction' können aufgelöst werden.",
+ "ModalMsgMigrationNotFailed": "Migration '%s' befindet sich nicht in einem fehlgeschlagenen Zustand.",
+ "ModalMsgMigrationMarkedSuccess": "Migration erfolgreich als %s markiert!",
+ "ModalMsgMigrateResolveFailedWithCode": "prisma migrate resolve mit Exit-Code fehlgeschlagen: %d",
+ "ModalMsgFailedRunMigrateResolve": "prisma migrate resolve konnte nicht ausgeführt werden:",
+ "ModalMsgFailedStartMigrateResolve": "Migrate Resolve konnte nicht gestartet werden:",
+ "ModalMsgFailedStopStudio": "Prisma Studio konnte nicht gestoppt werden:",
+ "ModalMsgStudioStopped": "Prisma Studio wurde gestoppt.",
+ "ModalMsgFailedStartStudio": "Prisma Studio konnte nicht gestartet werden:",
+ "ModalMsgStudioRunningAt": "Prisma Studio läuft unter http://localhost:5555",
+ "ModalMsgPressStopStudio": "Drücken Sie erneut 'S', um es zu stoppen.",
+ "ModalMsgSelectMigrationDelete": "Bitte wählen Sie eine Migration zum Löschen aus.",
+ "ModalMsgMigrationDBOnly": "Diese Migration existiert nur in der Datenbank (Nur-DB).",
+ "ModalMsgCannotDeleteNoLocalFile": "Eine Migration ohne lokale Datei kann nicht gelöscht werden.",
+ "ModalMsgMigrationAlreadyApplied": "Diese Migration wurde bereits auf die Datenbank angewendet.",
+ "ModalMsgDeleteLocalInconsistency": "Lokales Löschen würde Inkonsistenzen verursachen.",
+ "ModalMsgFailedCreateFolder": "Migrationsordner konnte nicht erstellt werden:",
+ "ModalMsgFailedDeleteFolder": "Migrationsordner konnte nicht gelöscht werden:",
+ "ModalMsgFailedWriteMigrationFile": "Migrationsdatei konnte nicht geschrieben werden:",
+ "ModalMsgMigrationDeletedSuccess": "Migration erfolgreich gelöscht.",
+ "ModalMsgFailedCopyClipboard": "Kopieren in die Zwischenablage fehlgeschlagen:",
+ "ModalMsgCopiedToClipboard": "%s in die Zwischenablage kopiert!",
+ "ModalMsgPendingMigrationsWarning": "Prisma wendet ausstehende Migrationen automatisch an, bevor neue erstellt werden. Dies kann in Zukunft zu unerwartetem Verhalten führen. Möchten Sie fortfahren?",
+ "ModalMsgCannotCreateWithDBOnly": "Neue Migration kann nicht erstellt werden, solange Nur-DB-Migrationen vorhanden sind.",
+ "ModalMsgResolveDBOnlyFirst": "Bitte lösen Sie zuerst die Nur-DB-Migrationen auf.",
+ "ModalMsgCannotCreateWithMismatch": "Neue Migration kann nicht erstellt werden, solange Prüfsummen-Abweichungen bestehen.",
+ "ModalMsgMigrationModifiedLocally": "Migration '%s' wurde lokal geändert.",
+ "ModalMsgCannotCreateWithEmpty": "Neue Migration kann nicht erstellt werden, solange leere ausstehende Migrationen vorhanden sind.",
+ "ModalMsgMigrationPendingEmpty": "Migration '%s' ist ausstehend und leer.",
+ "ModalMsgDeleteOrAddContent": "Bitte löschen Sie sie oder fügen Sie SQL-Inhalt hinzu.",
+ "ModalMsgAnotherOperationRunning": "Ein anderer Vorgang wird derzeit ausgeführt.",
+ "ModalMsgWaitComplete": "Bitte warten Sie, bis er abgeschlossen ist.",
+ "ModalMsgConfirmDeleteMigration": "Sind Sie sicher, dass Sie diese Migration löschen möchten?\n\n%s\n\nDiese Aktion kann nicht rückgängig gemacht werden.",
+ "ModalMsgSpacesReplaced": "Leerzeichen werden durch Unterstriche ersetzt",
+ "ModalMsgInputRequired": "Eingabe erforderlich",
+ "ModalMsgManualMigrationCreated": "Erstellt: %s",
+ "ModalMsgManualMigrationLocation": "Speicherort: %s",
+ "CopyLabelMigrationName": "Migrationsname",
+ "CopyLabelMigrationPath": "Migrationspfad",
+ "CopyLabelChecksum": "Prüfsumme",
+
+ "ModalFooterInputSubmitCancel": "[Enter] Absenden [ESC] Abbrechen",
+ "ModalFooterListNavigate": "[↑/↓] Navigieren [Enter] Auswählen [ESC] Abbrechen",
+ "ModalFooterMessageClose": " [Enter/q/ESC] Schließen ",
+ "ModalFooterConfirmYesNo": " [Y] Ja [N] Nein [ESC] Abbrechen ",
+
+ "StatusStudioOn": "[Studio: AN]",
+
+ "ActionLabelApplied": "angewendet",
+ "ActionLabelRolledBack": "zurückgesetzt",
+
+ "LogActionMigrateDeploy": "Migrate Deploy",
+ "LogMsgRunningMigrateDeploy": "prisma migrate deploy wird ausgeführt...",
+ "LogActionMigrateDeployComplete": "Migrate Deploy abgeschlossen",
+ "LogMsgMigrationsAppliedSuccess": "Migrationen erfolgreich angewendet",
+ "LogActionMigrateDeployFailed": "Migrate Deploy fehlgeschlagen",
+ "LogMsgMigrateDeployFailedCode": "Migrate Deploy mit Exit-Code fehlgeschlagen: %d",
+ "LogActionMigrateResolve": "Migrate Resolve",
+ "LogMsgMarkingMigration": "Migration wird als %s markiert: %s",
+ "LogActionMigrateResolveComplete": "Migrate Resolve abgeschlossen",
+ "LogMsgMigrationMarked": "Migration erfolgreich als %s markiert",
+ "LogActionMigrateResolveFailed": "Migrate Resolve fehlgeschlagen",
+ "LogMsgMigrateResolveFailedCode": "Migrate Resolve mit Exit-Code fehlgeschlagen: %d",
+ "LogActionMigrateResolveError": "Migrate Resolve Fehler",
+ "LogActionGenerate": "Generierung",
+ "LogMsgRunningGenerate": "prisma generate wird ausgeführt...",
+ "LogActionGenerateComplete": "Generierung abgeschlossen",
+ "LogMsgPrismaClientGeneratedSuccess": "Prisma Client erfolgreich generiert",
+ "LogActionGenerateFailed": "Generierung fehlgeschlagen",
+ "LogMsgCheckingSchemaErrors": "Schema wird auf Fehler überprüft...",
+ "LogActionSchemaValidationFailed": "Schema-Validierung fehlgeschlagen",
+ "LogMsgFoundSchemaErrors": "%d Schema-Fehler gefunden",
+ "LogActionGenerateError": "Generierungsfehler",
+ "LogActionStudio": "Studio",
+ "LogMsgStartingStudio": "Prisma Studio wird gestartet...",
+ "LogActionStudioStarted": "Studio gestartet",
+ "LogMsgStudioListeningAt": "Prisma Studio läuft unter http://localhost:5555",
+ "LogActionStudioStopped": "Studio gestoppt",
+ "LogMsgStudioHasStopped": "Prisma Studio wurde gestoppt",
+ "LogActionMigrateDev": "Migrate Dev",
+ "LogMsgCreatingMigration": "Migration wird erstellt: %s",
+ "LogActionMigrateComplete": "Migration abgeschlossen",
+ "LogMsgMigrationCreatedSuccess": "Migration erfolgreich erstellt",
+ "LogActionMigrateFailed": "Migration fehlgeschlagen",
+ "LogMsgMigrationCreationFailedCode": "Migrationserstellung mit Exit-Code fehlgeschlagen: %d",
+ "LogActionMigrationError": "Migrationsfehler",
+ "LogMsgFailedDeleteMigration": "Migration konnte nicht gelöscht werden: %s",
+ "LogActionDeleted": "Gelöscht",
+ "LogMsgMigrationDeleted": "Migration '%s' gelöscht",
+ "SuccessAllPanelsRefreshed": "Alle Panels wurden aktualisiert",
+ "ActionRefresh": "Aktualisieren",
+
+ "ListItemSchemaDiffMigration": "Schema-Diff-basierte Migration",
+ "ListItemDescSchemaDiffMigration": "Erstellt eine Migration basierend auf Änderungen im Prisma-Schema, wendet sie auf die Datenbank an und löst Generatoren aus (z.B. Prisma Client)",
+ "ListItemManualMigration": "Manuelle Migration",
+ "ListItemDescManualMigration": "Dieses Tool erstellt manuelle Migrationen für Datenbankänderungen, die nicht über Prisma-Schema-Diff ausgedrückt werden können. Es wird verwendet, um datenbankspezifische Logik wie Trigger, Funktionen und DML-Operationen explizit zu erfassen und zu versionieren, die auf Prisma-Schema-Ebene nicht verwaltet werden können.",
+ "ListItemMarkApplied": "Als angewendet markieren",
+ "ListItemDescMarkApplied": "Markiert diese Migration als erfolgreich auf die Datenbank angewendet. Verwenden Sie dies, wenn Sie das Problem manuell behoben haben und die Migrationsänderungen nun in der Datenbank vorhanden sind.",
+ "ListItemMarkRolledBack": "Als zurückgesetzt markieren",
+ "ListItemDescMarkRolledBack": "Markiert diese Migration als zurückgesetzt (aus der Datenbank entfernt). Verwenden Sie dies, wenn Sie die Änderungen manuell rückgängig gemacht haben und die Migration nicht mehr auf die Datenbank angewendet ist.",
+ "ListItemCopyName": "Name kopieren",
+ "ListItemCopyPath": "Pfad kopieren",
+ "ListItemCopyChecksum": "Prüfsumme kopieren",
+
+ "MigrationStatusInTransaction": "⚠ In-Transaction",
+ "MigrationStatusDBOnly": "✗ Nur DB",
+ "MigrationStatusChecksumMismatch": "⚠ Prüfsummen-Abweichung",
+ "MigrationStatusApplied": "✓ Angewendet",
+ "MigrationStatusEmptyMigration": "⚠ Leere Migration",
+ "MigrationStatusPending": "⚠ Ausstehend",
+
+ "DetailsPanelInitialPlaceholder": "Details\n\nWählen Sie eine Migration aus, um Details anzuzeigen...",
+ "DetailsNameLabel": "Name: %s\n",
+ "DetailsTimestampLabel": "Zeitstempel: %s\n",
+ "DetailsPathLabel": "Pfad: %s\n",
+ "DetailsStatusLabel": "Status: ",
+ "DetailsAppliedAtLabel": "Angewendet am: %s",
+ "DetailsDownMigrationLabel": "Down-Migration: ",
+ "DetailsDownMigrationAvailable": "✓ Verfügbar",
+ "DetailsDownMigrationNotAvailable": "✗ Nicht verfügbar",
+ "DetailsStartedAtLabel": "Gestartet am: ",
+ "DetailsInTransactionWarning": "⚠ WARNUNG: Diese Migration befindet sich in einem unvollständigen Zustand.",
+ "DetailsNoAdditionalMigrationsWarning": "Keine weiteren Migrationen können angewendet werden, bis dies gelöst ist.",
+ "DetailsResolveManuallyInstruction": "Bitte lösen Sie diese Migration manuell auf, bevor Sie fortfahren.\n",
+ "DetailsErrorLogsLabel": "Fehlerprotokolle:",
+ "DetailsDBOnlyDescription": "Diese Migration existiert in der Datenbank, aber nicht in lokalen Dateien.",
+ "DetailsChecksumModifiedDescription": "Die lokale Migrationsdatei wurde nach der Anwendung auf die Datenbank geändert.\n",
+ "DetailsChecksumIssuesWarning": "Dies kann beim Deployment zu Problemen führen.\n\n",
+ "DetailsLocalChecksumLabel": "Lokale Prüfsumme: ",
+ "DetailsHistoryChecksumLabel": "Verlauf-Prüfsumme: ",
+ "DetailsEmptyMigrationDescription": "Dieser Migrationsordner ist leer oder migration.sql fehlt.\n",
+ "DetailsEmptyMigrationWarning": "Dies kann beim Deployment zu Problemen führen.",
+ "DetailsDownMigrationSQLLabel": "Down-Migration SQL:",
+ "ErrorReadingMigrationSQL": "Fehler beim Lesen von migration.sql:\n%v",
+
+ "ActionNeededNoIssuesMessage": "Kein Handlungsbedarf\n\nAlle Migrationen sind in gutem Zustand und das Schema ist gültig.",
+ "ActionNeededHeader": "⚠ Handlungsbedarf",
+ "ActionNeededIssueSingular": " Problem",
+ "ActionNeededIssuePlural": "e",
+ "ActionNeededEmptyMigrationsHeader": "Leere Migrationen",
+ "ActionNeededEmptyDescription": "Diese Migrationen haben keinen SQL-Inhalt.\n\n",
+ "ActionNeededAffectedLabel": "Betroffen:\n",
+ "ActionNeededRecommendedLabel": "Empfohlene Maßnahmen:\n",
+ "ActionNeededAddMigrationSQL": " → migration.sql manuell hinzufügen\n",
+ "ActionNeededDeleteEmptyFolders": " → Leere Migrationsordner löschen\n",
+ "ActionNeededMarkAsBaseline": " → Als Baseline-Migration markieren\n\n",
+ "ActionNeededChecksumMismatchHeader": "Prüfsummen-Abweichung",
+ "ActionNeededChecksumModifiedDescription": "Migrationsinhalt wurde nach der Anwendung\nauf die Datenbank geändert.\n\n",
+ "ActionNeededWarningPrefix": "⚠ WARNUNG: ",
+ "ActionNeededEditingInconsistenciesWarning": "Bearbeitung angewendeter Migrationen\nkann Inkonsistenzen verursachen.\n\n",
+ "ActionNeededRevertLocalChanges": " → Lokale Änderungen rückgängig machen\n",
+ "ActionNeededCreateNewInstead": " → Stattdessen neue Migration erstellen\n",
+ "ActionNeededContactTeamIfNeeded": " → Bei Bedarf das Team kontaktieren\n\n",
+ "ActionNeededSchemaValidationErrorsHeader": "Schema-Validierungsfehler",
+ "ActionNeededSchemaValidationFailedDesc": "Schema-Validierung fehlgeschlagen.\n",
+ "ActionNeededFixBeforeMigration": "Beheben Sie diese Probleme, bevor Sie Migrationen ausführen.\n\n",
+ "ActionNeededValidationOutputLabel": "Validierungsausgabe:",
+ "ActionNeededRecommendedActionsLabel": "Empfohlene Maßnahmen:",
+ "ActionNeededFixSchemaErrors": " → schema.prisma-Fehler beheben\n",
+ "ActionNeededCheckLineNumbers": " → Zeilennummern in der obigen Ausgabe überprüfen\n",
+ "ActionNeededReferPrismaDocumentation": " → Prisma-Dokumentation konsultieren\n",
+
+ "WorkspaceVersionLine": "Node: %s | Prisma: %s",
+ "WorkspacePrismaGlobalIndicator": " (Global)",
+ "WorkspaceGitLine": "Git: %s",
+ "WorkspaceSchemaModifiedIndicator": " (Schema geändert)",
+ "WorkspaceBranchFormat": "(%s)",
+ "WorkspaceNotGitRepository": "Git: Kein Git-Repository",
+ "WorkspaceConnected": "✓ Verbunden",
+ "WorkspaceNotConfigured": "✗ Nicht konfiguriert",
+ "WorkspaceDisconnected": "✗ Nicht verbunden",
+ "WorkspaceProviderLine": "Anbieter: %s %s",
+ "WorkspaceHardcodedIndicator": " (Fest codiert)",
+ "WorkspaceNotSet": "Nicht gesetzt",
+ "WorkspaceErrorFormat": "Fehler: %s",
+ "WorkspaceErrorGetWorkingDirectory": "Fehler beim Ermitteln des Arbeitsverzeichnisses",
+ "WorkspaceErrorSchemaNotFound": "Schema-Datei nicht gefunden",
+ "WorkspaceNotConfiguredSuffix": " nicht konfiguriert",
+ "WorkspaceDatabaseURLNotConfigured": "DATABASE_URL nicht konfiguriert",
+ "WorkspaceNoDatabaseURL": "Keine DATABASE_URL",
+ "WorkspaceVersionNotFound": "Nicht gefunden",
+
+ "MigrationsFooterFormat": "%d von %d",
+
+ "VersionOutput": "LazyPrisma %s (%s)\n",
+ "ErrorFailedGetCurrentDir": "Fehler: Aktuelles Verzeichnis konnte nicht ermittelt werden: %v\n",
+ "ErrorNotPrismaWorkspace": "Fehler: Das aktuelle Verzeichnis ist kein Prisma-Arbeitsbereich.\n",
+ "ErrorExpectedOneOf": "\nErwartet wird eines der folgenden:\n",
+ "ErrorExpectedConfigV7Plus": " - prisma.config.ts (Prisma v7.0+)\n",
+ "ErrorExpectedSchemaV7Minus": " - prisma/schema.prisma (Prisma < v7.0)\n",
+ "ErrorFailedCreateApp": "App konnte nicht erstellt werden: %v\n",
+ "ErrorFailedRegisterKeybindings": "Tastenbelegungen konnten nicht registriert werden: %v\n",
+ "ErrorAppRuntime": "App-Fehler: %v\n"
+}
diff --git a/pkg/prisma/workspace.go b/pkg/prisma/workspace.go
index 5266cc0..473f474 100644
--- a/pkg/prisma/workspace.go
+++ b/pkg/prisma/workspace.go
@@ -9,7 +9,7 @@ const (
// v7.0+ config file
ConfigFileName = "prisma.config.ts"
- // v7.0 이전 schema file
+ // Schema file prior to v7.0
SchemaFileName = "schema.prisma"
SchemaDirName = "prisma"
)