commit 4ebfacb985bb8c25c25f2a8e18b4e82899ac189f
Author: Yongbin Kim <iam@yongbin.kim>
Date: Fri, 17 Feb 2023 22:17:52 +0900
🎉 First Commit
Signed-off-by: Yongbin Kim <iam@yongbin.kim>
Diffstat:
A | .vscode/launch.json | | | 17 | +++++++++++++++++ |
A | LICENSE | | | 21 | +++++++++++++++++++++ |
A | README | | | 46 | ++++++++++++++++++++++++++++++++++++++++++++++ |
A | action_selector.go | | | 63 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | app.go | | | 134 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | config.go | | | 27 | +++++++++++++++++++++++++++ |
A | go.mod | | | 29 | +++++++++++++++++++++++++++++ |
A | go.sum | | | 64 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | help.go | | | 8 | ++++++++ |
A | keys.go | | | 58 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | list_helpers.go | | | 11 | +++++++++++ |
A | main.go | | | 206 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | project_list.go | | | 16 | ++++++++++++++++ |
A | projects.go | | | 81 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | styles.go | | | 14 | ++++++++++++++ |
A | utils.go | | | 27 | +++++++++++++++++++++++++++ |
16 files changed, 822 insertions(+), 0 deletions(-)
diff --git a/.vscode/launch.json b/.vscode/launch.json
@@ -0,0 +1,16 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Launch Package",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "program": "${fileDirname}",
+ "console": "integratedTerminal"
+ }
+ ]
+}
+\ No newline at end of file
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Yongbin Kim
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README b/README
@@ -0,0 +1,46 @@
+Project manager for macOS
+
+Maybe unstable. Use at your own risk.
+
+# Usage
+
+Save this file to `~/.config/ybkimm/project-manager.json`.
+
+```
+{
+ "projects": "/Users/ybkimm/Dropbox/Development/Projects",
+ "actives": "/Users/ybkimm/Dropbox/Development/ActiveProjects",
+ "archives": "/Users/ybkimm/Dropbox/Development/ArchivedProjects"
+}
+```
+
+Done! Now you can use `project-manager` command.
+
+```
+$ project-manager
+
+ Projects
+
+ > [x] project 1
+ [ ] project 2
+ [x] project 3
+
+```
+
+## Controls
+
+- `Up`/`Down`: Move up/down
+- `Left/Right`: Move to the previous/next page
+- `Space`: Toggle active/inactive
+- `Enter`: Open the project
+- `Esc`: Cancel current action, Quit and save
+- `Ctrl + C`: Quit without saving
+- `/`: Search
+
+# Contact
+
+Please send me an email to <iam@yongbin.kim>.
+
+# License
+
+MIT
diff --git a/action_selector.go b/action_selector.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func newActionSelectorModel() list.Model {
+ items := []list.Item{
+ actionItem{"code %s", "Open in Visual Studio Code"},
+ actionItem{"open %s", "Open in Finder"},
+ actionItem{`cd %s; exec "${SHELL:-sh}"`, "Start new terminal session in project directory"},
+ }
+
+ model := list.New(items, actionItemDelegate{}, defaultWidth, listHeight)
+ model.Title = "Actions"
+ setupList(&model)
+
+ return model
+}
+
+type actionItem struct {
+ command string
+ label string
+}
+
+func (i actionItem) FilterValue() string {
+ return i.label
+}
+
+type actionItemDelegate struct{}
+
+func (d actionItemDelegate) Height() int {
+ return 1
+}
+
+func (d actionItemDelegate) Spacing() int {
+ return 0
+}
+
+func (d actionItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+ return nil
+}
+
+func (d actionItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+ fmt.Fprint(w, d.render(m, index, listItem))
+}
+
+func (d actionItemDelegate) render(m list.Model, index int, listItem list.Item) string {
+ action, ok := listItem.(actionItem)
+ if !ok {
+ return ""
+ }
+
+ if index == m.Index() {
+ return selectedItemStyle.Render("> " + action.label)
+ } else {
+ return itemStyle.Render(action.label)
+ }
+}
diff --git a/app.go b/app.go
@@ -0,0 +1,134 @@
+package main
+
+import (
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+const (
+ defaultWidth = 20
+ listHeight = 14
+)
+
+type appModel struct {
+ projects []*Project
+
+ projectList list.Model
+ actionSelector list.Model
+ help help.Model
+
+ selectedProject *Project
+ selectedAction string
+ showActionSelector bool
+ shouldSave bool
+ quitting bool
+}
+
+func newAppModel(projects []*Project) *appModel {
+ return &appModel{
+ projects: projects,
+ projectList: newProjectListModel(projects),
+ actionSelector: newActionSelectorModel(),
+ help: initialHelp(),
+ }
+}
+
+func (m appModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ h, _ := appStyle.GetFrameSize()
+ width := msg.Width - h
+ m.actionSelector.SetSize(width, listHeight)
+ m.projectList.SetSize(width, listHeight)
+
+ case tea.KeyMsg:
+ switch {
+
+ case key.Matches(msg, keys.ForceQuit):
+ m.shouldSave = false
+ m.quitting = true
+ return m, tea.Quit
+ }
+ }
+
+ var cmds []tea.Cmd
+
+ if m.showActionSelector {
+ cmds = append(cmds, m.updateActionSelector(msg))
+ } else {
+ cmds = append(cmds, m.updateProjectList(msg))
+ }
+
+ return m, tea.Batch(cmds...)
+}
+
+func (m *appModel) updateActionSelector(msg tea.Msg) tea.Cmd {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, keys.CloseActionSelector):
+ m.showActionSelector = false
+ m.selectedProject = nil
+ return nil
+
+ case key.Matches(msg, keys.Select):
+ action := m.actionSelector.SelectedItem().(actionItem)
+ m.selectedAction = action.command
+ m.showActionSelector = false
+ m.shouldSave = true
+ m.quitting = true
+ return tea.Quit
+ }
+ }
+
+ var cmd tea.Cmd
+ m.actionSelector, cmd = m.actionSelector.Update(msg)
+ return cmd
+}
+
+func (m *appModel) updateProjectList(msg tea.Msg) tea.Cmd {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, keys.Toggle):
+ project := m.projectList.SelectedItem().(*Project)
+ project.IsActive = !project.IsActive
+ return nil
+
+ case key.Matches(msg, keys.Select):
+ project := m.projectList.SelectedItem().(*Project)
+ m.selectedProject = project
+ m.showActionSelector = true
+ return nil
+
+ case key.Matches(msg, keys.Quit):
+ m.shouldSave = true
+ m.quitting = true
+ return tea.Quit
+ }
+ }
+
+ var cmd tea.Cmd
+ m.projectList, cmd = m.projectList.Update(msg)
+ return cmd
+}
+
+func (m appModel) View() string {
+ if m.quitting {
+ return ""
+ }
+ var views []string
+ if m.showActionSelector {
+ views = append(views, m.actionSelector.View())
+ } else {
+ views = append(views, m.projectList.View())
+ }
+ return appStyle.Render(lipgloss.JoinVertical(lipgloss.Left, views...))
+}
diff --git a/config.go b/config.go
@@ -0,0 +1,27 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+)
+
+type Config struct {
+ ProjectDirectory string `json:"projects"`
+ ActiveDirectory string `json:"actives"`
+ ArchivedDirectory string `json:"archives"`
+}
+
+func loadConfig(path string) (*Config, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var config Config
+ err = json.Unmarshal(data, &config)
+ if err != nil {
+ return nil, err
+ }
+
+ return &config, nil
+}
diff --git a/go.mod b/go.mod
@@ -0,0 +1,29 @@
+module go.lair.cx/project-manager
+
+go 1.19
+
+require (
+ github.com/charmbracelet/bubbles v0.15.0
+ github.com/charmbracelet/bubbletea v0.23.2
+ github.com/charmbracelet/lipgloss v0.6.0
+ golang.org/x/sys v0.5.0
+)
+
+require (
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
+ github.com/containerd/console v1.0.3 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-isatty v0.0.17 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.14 // indirect
+ github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/reflow v0.3.0 // indirect
+ github.com/muesli/termenv v0.14.0 // indirect
+ github.com/rivo/uniseg v0.4.3 // indirect
+ github.com/sahilm/fuzzy v0.1.0 // indirect
+ golang.org/x/sync v0.1.0 // indirect
+ golang.org/x/term v0.5.0 // indirect
+ golang.org/x/text v0.7.0 // indirect
+)
diff --git a/go.sum b/go.sum
@@ -0,0 +1,64 @@
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
+github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E=
+github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
+github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI=
+github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74=
+github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU=
+github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps=
+github.com/charmbracelet/bubbletea v0.23.2/go.mod h1:FaP3WUivcTM0xOKNmhciz60M6I+weYLF76mr1JyI7sM=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
+github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY=
+github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
+github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+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-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a h1:jlDOeO5TU0pYlbc/y6PFguab5IjANI0Knrpg3u/ton4=
+github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
+github.com/muesli/termenv v0.14.0 h1:8x9NFfOe8lmIWK4pgy3IfVEy47f+ppe3tUqdPZG2Uy0=
+github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
+github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/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-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/help.go b/help.go
@@ -0,0 +1,8 @@
+package main
+
+import "github.com/charmbracelet/bubbles/help"
+
+func initialHelp() help.Model {
+ model := help.New()
+ return model
+}
diff --git a/keys.go b/keys.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/list"
+)
+
+// keyMap defines a set of keybindings. To work for help it must satisfy
+// key.Map. It could also very easily be a map[string]key.Binding.
+type keyMap struct {
+ list.KeyMap
+
+ Quit key.Binding
+ ForceQuit key.Binding
+ CloseActionSelector key.Binding
+ Select key.Binding
+ Toggle key.Binding
+}
+
+// ShortHelp returns keybindings to be shown in the mini help view. It's part
+// of the key.Map interface.
+func (k keyMap) ShortHelp() []key.Binding {
+ return []key.Binding{k.ShowFullHelp, k.Quit}
+}
+
+// FullHelp returns keybindings for the expanded help view. It's part of the
+// key.Map interface.
+func (k keyMap) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {k.CursorUp, k.CursorDown, k.NextPage, k.NextPage}, // first column
+ {k.CloseFullHelp, k.Quit, k.ForceQuit}, // second column
+ }
+}
+
+var keys = keyMap{
+ KeyMap: list.DefaultKeyMap(),
+
+ Quit: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "quit and save all changes"),
+ ),
+ ForceQuit: key.NewBinding(
+ key.WithKeys("ctrl+c"),
+ key.WithHelp("ctrl+c", "quit without saving changes"),
+ ),
+ CloseActionSelector: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "clear filter"),
+ ),
+ Select: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "cd to project"),
+ ),
+ Toggle: key.NewBinding(
+ key.WithKeys(" "),
+ key.WithHelp("space", "toggle active"),
+ ),
+}
diff --git a/list_helpers.go b/list_helpers.go
@@ -0,0 +1,11 @@
+package main
+
+import "github.com/charmbracelet/bubbles/list"
+
+func setupList(model *list.Model) {
+ model.SetShowStatusBar(false)
+ model.SetShowHelp(false)
+ model.DisableQuitKeybindings()
+ model.Styles.Title = titleStyle
+ model.Styles.PaginationStyle = paginationStyle
+}
diff --git a/main.go b/main.go
@@ -0,0 +1,206 @@
+// Command project-manager is a tool to manage projects.
+// Usage:
+//
+// project-manager [flags]
+//
+// The flags are:
+//
+// -config string
+// Path to config file (default "~/.config/ybkimm/project-manager.json")
+//
+// Configuration example:
+//
+// { "project-directory": "/path/to/projects", "active-directory": "/path/to/active/projects" }
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+ "unsafe"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "golang.org/x/sys/unix"
+)
+
+var (
+ flagConfig = flag.String(
+ "config",
+ "~/.config/ybkimm/project-manager.json",
+ "Path to config file",
+ )
+)
+
+func main() {
+ flag.Parse()
+
+ configPath, err := expandPath(*flagConfig)
+ if err != nil {
+ panic(err)
+ }
+
+ config, err := loadConfig(configPath)
+ if err != nil {
+ panic(err)
+ }
+
+ projects, err := GetProjects(config)
+ if err != nil {
+ panic(err)
+ }
+
+ p := tea.NewProgram(newAppModel(projects))
+
+ m, err := run(p)
+ if err != nil {
+ panic(err)
+ }
+
+ if m.shouldSave {
+ fmt.Println("Saving changes...")
+ if err := saveChanges(config, m); err != nil {
+ panic(err)
+ }
+ fmt.Println("Done.")
+ }
+
+ if len(m.selectedAction) > 0 && m.selectedProject != nil {
+ projectPath := filepath.Join(config.ProjectDirectory, m.selectedProject.Name)
+ action := fmt.Sprintf(m.selectedAction, projectPath)
+
+ err := executeAction(action)
+ if err != nil {
+ panic(err)
+ }
+ }
+}
+
+func run(p *tea.Program) (appModel, error) {
+ m, err := p.Run()
+ if err != nil {
+ return appModel{}, err
+ }
+
+ return m.(appModel), nil
+}
+
+func saveChanges(config *Config, m appModel) error {
+ for _, project := range m.projects {
+ if project.IsActive {
+ if err := activateProject(config, project); err != nil {
+ return err
+ }
+ } else {
+ if err := deactivateProject(config, project); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func getPaths(config *Config, project string) (projectPath, activePath, archivePath string) {
+ projectPath = filepath.Join(config.ProjectDirectory, project)
+ activePath = filepath.Join(config.ActiveDirectory, project)
+ archivePath = filepath.Join(config.ArchivedDirectory, project)
+
+ return
+}
+
+func activateProject(config *Config, project *Project) error {
+ projectPath, activePath, archivePath := getPaths(config, project.Name)
+
+ err := removeSymlink(archivePath)
+ if err != nil {
+ return err
+ }
+
+ return createSymlink(projectPath, activePath)
+}
+
+func deactivateProject(config *Config, project *Project) error {
+ projectPath, activePath, archivePath := getPaths(config, project.Name)
+
+ err := removeSymlink(activePath)
+ if err != nil {
+ return err
+ }
+
+ return createSymlink(projectPath, archivePath)
+}
+
+// createSymlink creates symlink.
+// if symlink is already exists, ignore.
+func createSymlink(src, dst string) error {
+ if _, err := os.Stat(dst); os.IsNotExist(err) {
+ fmt.Println("Creating symlink:", src, "->", dst)
+ return os.Symlink(src, dst)
+ } else if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// removeSymlink removes symlink.
+// if symlink is not exists, ignore.
+// if path is not symlink, it warns and returns nil.
+func removeSymlink(path string) error {
+ stat, err := os.Lstat(path)
+ if os.IsNotExist(err) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+
+ isSymlink := stat.Mode()&os.ModeSymlink != 0
+ if isSymlink {
+ fmt.Println("Removing symlink:", path)
+ return os.Remove(path)
+ }
+
+ fmt.Println("Warning: Not symlink:", path)
+ return nil
+}
+
+func executeAction(action string) error {
+ fmt.Println("Executing action:", action)
+
+ // Get the current terminal settings.
+ termios, err := unix.IoctlGetTermios(syscall.Stdin, unix.TIOCGETA)
+ if err != nil {
+ return err
+ }
+
+ // Set the new terminal settings to be the same as the current settings.
+ newTermios := *termios
+
+ // Enable canonical mode, which sets the terminal to send complete lines of input
+ // to the shell process.
+ newTermios.Lflag |= syscall.ICANON
+
+ // Disable echo mode, which stops the terminal from echoing input characters.
+ newTermios.Lflag &^= syscall.ECHO
+
+ // Set the new terminal settings.
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_IOCTL,
+ uintptr(syscall.Stdin),
+ uintptr(syscall.TIOCSETA),
+ uintptr(unsafe.Pointer(&newTermios)),
+ )
+ if errno != 0 {
+ return errno
+ }
+
+ // Replace the current process with the shell process.
+ err = syscall.Exec("/bin/sh", []string{"/bin/sh", "-c", action}, os.Environ())
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/project_list.go b/project_list.go
@@ -0,0 +1,16 @@
+package main
+
+import "github.com/charmbracelet/bubbles/list"
+
+func newProjectListModel(projects []*Project) list.Model {
+ items := make([]list.Item, len(projects))
+ for i, project := range projects {
+ items[i] = project
+ }
+
+ model := list.New(items, projectItemDelegate{}, defaultWidth, listHeight)
+ model.Title = "Projects"
+ setupList(&model)
+
+ return model
+}
diff --git a/projects.go b/projects.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type Project struct {
+ Name string
+ IsActive bool
+}
+
+func GetProjects(config *Config) ([]*Project, error) {
+ projects := []*Project{}
+
+ dirs, err := os.ReadDir(config.ProjectDirectory)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, dir := range dirs {
+ if !dir.IsDir() {
+ continue
+ }
+
+ isActive := exists(path.Join(config.ActiveDirectory, dir.Name()))
+ projects = append(projects, &Project{
+ Name: dir.Name(),
+ IsActive: isActive,
+ })
+ }
+
+ return projects, nil
+}
+
+func (p *Project) FilterValue() string {
+ return p.Name
+}
+
+type projectItemDelegate struct{}
+
+func (d projectItemDelegate) Height() int {
+ return 1
+}
+
+func (d projectItemDelegate) Spacing() int {
+ return 0
+}
+
+func (d projectItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+ return nil
+}
+
+func (d projectItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+ fmt.Fprint(w, d.render(m, index, listItem))
+}
+
+func (d projectItemDelegate) render(m list.Model, index int, listItem list.Item) string {
+ project, ok := listItem.(*Project)
+ if !ok {
+ return ""
+ }
+
+ checked := " "
+ if project.IsActive {
+ checked = "x"
+ }
+
+ str := fmt.Sprintf("[%s] %s", checked, project.Name)
+
+ if index == m.Index() {
+ return selectedItemStyle.Render("> " + str)
+ } else {
+ return itemStyle.Render(str)
+ }
+}
diff --git a/styles.go b/styles.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+ "github.com/charmbracelet/bubbles/list"
+ "github.com/charmbracelet/lipgloss"
+)
+
+var (
+ appStyle = lipgloss.NewStyle().Padding(1, 2)
+ titleStyle = lipgloss.NewStyle()
+ itemStyle = lipgloss.NewStyle().PaddingLeft(4)
+ selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
+ paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
+)
diff --git a/utils.go b/utils.go
@@ -0,0 +1,27 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func exists(path string) bool {
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ return false
+ }
+ return true
+}
+
+func expandPath(path string) (string, error) {
+ if !strings.HasPrefix(path, "~") {
+ return path, nil
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+
+ return filepath.Clean(strings.Replace(path, "~", home, 1)), nil
+}