project-manager

Project directory management for macOS
git clone git://git.lair.cx/project-manager
Log | Files | Refs | README | LICENSE

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+++++++++++++++++
ALICENSE | 21+++++++++++++++++++++
AREADME | 46++++++++++++++++++++++++++++++++++++++++++++++
Aaction_selector.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp.go | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aconfig.go | 27+++++++++++++++++++++++++++
Ago.mod | 29+++++++++++++++++++++++++++++
Ago.sum | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahelp.go | 8++++++++
Akeys.go | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alist_helpers.go | 11+++++++++++
Amain.go | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aproject_list.go | 16++++++++++++++++
Aprojects.go | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astyles.go | 14++++++++++++++
Autils.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 +}