DEV Community: MABD The latest articles on DEV Community by MABD (@mabd_dev). https://dev.to/mabd_dev https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3661171%2Fd0c143e5-d9a3-43ee-ba77-cab23e01b4bd.png DEV Community: MABD https://dev.to/mabd_dev en Building a Vim-Powered Jira Client with Compose Multiplatform & Claude MABD Mon, 23 Feb 2026 05:33:00 +0000 https://dev.to/mabd_dev/building-a-vim-powered-jira-client-with-compose-multiplatform-claude-1djb https://dev.to/mabd_dev/building-a-vim-powered-jira-client-with-compose-multiplatform-claude-1djb <blockquote> <p>Jira is powerful — but painfully slow for keyboard-driven workflows.</p> </blockquote> <p>As a daily <a href="proxy.php?url=https://www.vim.org/" rel="noopener noreferrer">vim</a> user I am not satisfied with the experience. After years of trying to embrace it, I finally decided to build my own solution. So I built a keyboard-first Jira client powered by a custom Vim engine using Compose Multiplatform.</p> <p><strong>To be clear</strong>: this is not Jira replacement. You can still use Jira normally. I will be fetching Jira data through their official API, and show to you in a nicer way + vim </p> <h2> The Problem </h2> <ul> <li>UI changes very frequently</li> <li>UI is clunky and slow</li> <li>You see too many elements at your face, even if you don’t use them</li> <li>Planning is hard</li> <li>Mouse-heavy workflow</li> </ul> <h2> The Goals </h2> <p>Have a keyboard-first navigation system with the option to also use a mouse and keyboard. Data would be fetched from official Jira api and display on a multiplatform application on your desktop or mobile phone.</p> <p>Since the data are the same, this would allow me to use this app or Jira whenever I want to. Of course while building this app, I would still be using Jira for features that won’t be supported in the app yet.</p> <p>The app UI will be modern, configurable, supports small screens (phone) and large screens (tablets + laptops). This would be powered by compose multiplatform. More on that later</p> <h2> The architecture </h2> <p>Before starting with UI stuff, I need to have vim like engine to be working, at least the basic usage for now and will improve as I go. I need a way to hit keystrokes on my keyboard and convert those to events. Like <code>move-up</code>, <code>move-down</code>, <code>click</code>, <code>filter</code>, <code>assign task</code>, etc… This means I need to listen to keyboard strokes and handle them properly</p> <p>To understand how to do this nicely, We need to know how vim is done and how it is used on the terminal</p> <p>At a high level, the system is composed of:</p> <ul> <li>VimEngine (mode-aware input processor)</li> <li>Mode Parsers (Normal, Command)</li> <li>MVI ViewModel layer</li> <li>Remote + Local data sources</li> <li>Compose Multiplatform UI</li> </ul> <h3> Vim Modes </h3> <p>Vim has many <strong>modes</strong> like:</p> <ul> <li> <strong>normal</strong>: while navigating a file</li> <li> <strong>insert</strong>: while writing to a file</li> <li> <strong>command</strong>: when running commands in a file</li> <li> <strong>visual</strong>: selecting text in a file</li> <li> <strong>v-line</strong>: selecting lines in a file and a few more modes</li> </ul> <p>For now, I will only handle <code>normal</code>, <code>command</code> modes<br> Each mode handles keystrokes differently</p> <h3> Normal Mode Parser </h3> <p>This mode need to parse and optionally handle keystrokes as soon as they are received. For example, if user press <code>j</code>, this should be parsed and understood and <code>move down</code>, <code>k</code> for <code>moving up</code> and so on</p> <p>This is simple, we could do something like this<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight kotlin"><code><span class="k">fun</span> <span class="nf">handle</span><span class="p">(</span><span class="n">c</span><span class="p">:</span> <span class="nc">Char</span><span class="p">):</span> <span class="nc">VimAction</span><span class="p">?</span> <span class="p">{</span> <span class="k">return</span> <span class="k">when</span> <span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="p">{</span> <span class="sc">'j'</span> <span class="p">-&gt;</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">MoveDown</span> <span class="sc">'k'</span> <span class="p">-&gt;</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">MoveUp</span> <span class="k">else</span> <span class="p">-&gt;</span> <span class="k">null</span> <span class="p">}</span> <span class="p">}</span> </code></pre> </div> <p>What happens if I want to handle <code>gg</code> (move to top of the file), you might think i can simply add it to the when statement, like this<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight kotlin"><code><span class="k">fun</span> <span class="nf">handle</span><span class="p">(</span><span class="n">c</span><span class="p">:</span> <span class="nc">Char</span><span class="p">):</span> <span class="nc">VimAction</span><span class="p">?</span> <span class="p">{</span> <span class="k">return</span> <span class="k">when</span> <span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="p">{</span> <span class="c1">// ...</span> <span class="err">'</span><span class="n">gg</span><span class="err">'</span> <span class="p">-&gt;</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">MoveToTop</span> <span class="c1">// ...</span> <span class="p">}</span> </code></pre> </div> <p>but our handle function only send one character at a time. So we need to cache previous strokes. Then it becomes something like this<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight kotlin"><code><span class="kd">var</span> <span class="py">buffer</span> <span class="p">=</span> <span class="nc">StringBuilder</span><span class="p">()</span> <span class="k">fun</span> <span class="nf">handle</span><span class="p">(</span><span class="n">c</span><span class="p">:</span> <span class="nc">Char</span><span class="p">):</span> <span class="nc">VimAction</span><span class="p">?</span> <span class="p">{</span> <span class="k">when</span> <span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="p">{</span> <span class="sc">'j'</span> <span class="p">-&gt;</span> <span class="p">{</span> <span class="n">buffer</span><span class="p">.</span><span class="nf">clear</span><span class="p">()</span> <span class="k">return</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">MoveDown</span> <span class="p">}</span> <span class="sc">'k'</span> <span class="p">-&gt;</span> <span class="p">{</span> <span class="n">buffer</span><span class="p">.</span><span class="nf">clear</span><span class="p">()</span> <span class="k">return</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">MoveUp</span> <span class="p">}</span> <span class="p">}</span> <span class="n">buffer</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="k">when</span> <span class="p">(</span><span class="n">buffer</span><span class="p">.</span><span class="nf">toString</span><span class="p">())</span> <span class="p">{</span> <span class="err">'</span><span class="n">gg</span><span class="err">'</span> <span class="p">-&gt;</span> <span class="p">{</span> <span class="n">buffer</span><span class="p">.</span><span class="nf">clear</span><span class="p">()</span> <span class="k">return</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">MoveToTop</span> <span class="p">}</span> <span class="p">}</span> <span class="k">return</span> <span class="k">null</span> <span class="p">}</span> </code></pre> </div> <p>There are more complicated cases, like if user hit <code>g</code> by mistake and then he want to hit <code>j</code>. <br> This is step by step on what would happen<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>buffer = "" user clicked: g no match -&gt; buffer = "g" user clicked j no match -&gt; buffer = "gj" </code></pre> </div> <p>Now at this point no matter what user hits, buffer would keep increasing. We can clear buffer if user click <code>esc</code> but is not the vim way to handle it.</p> <h4> Partial Pattern Matching </h4> <p>If you try this in vim, hitting <code>g</code> then <code>j</code> would <code>move up</code>. How so?<br> This is called <strong>partial matches</strong>. <code>gj</code> is not a valid keybinding, but <code>j</code> itself is, so <code>g</code> is ignored and <code>j</code> will run</p> <p>If no exact match found, we try last n-1 characters on the buffer (n = number of characters in buffer), if that also had no match we try n-2 and so until no more characters in the buffer. But if we found a partial match, we return the corresponding <code>vim action</code> and we clear the buffer</p> <p>This type of pattern matching is needed in normal mode parser.</p> <h3> Command Mode Parser </h3> <p>Here we need to have a predefined grammar on our commands. Let’s start with a simple and familiar one<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>[verb] [target] (args) for example: :status done (change task status to done) :assign bob (assign task to bob) </code></pre> </div> <p>In this mode, we don’t want to parse on each keystroke, instead we keep adding to buffer and wait for user to hit <code>enter</code> to apply the command or <code>esc</code> to cancel it</p> <p>We should have a predefined vocabulary on each verb, then verb to target maps or something to know what we can handle and what we can’t</p> <p>Using power of sealed interface we can do this nicely as follows<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight kotlin"><code><span class="k">sealed</span> <span class="kd">interface</span> <span class="nc">CommandVerb</span> <span class="p">{</span> <span class="k">fun</span> <span class="nf">toVimAction</span><span class="p">(</span><span class="n">target</span><span class="p">:</span> <span class="nc">String</span><span class="p">?,</span> <span class="n">args</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Arg</span><span class="p">&gt;):</span> <span class="nc">VimAction</span><span class="p">?</span> <span class="n">data</span> <span class="kd">object</span> <span class="nc">Status</span><span class="p">:</span> <span class="nc">CommandVerb</span> <span class="p">{</span> <span class="k">override</span> <span class="k">fun</span> <span class="nf">toVimAction</span><span class="p">(</span><span class="n">target</span><span class="p">:</span> <span class="nc">String</span><span class="p">?,</span> <span class="n">args</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Arg</span><span class="p">&gt;):</span> <span class="nc">VimAction</span><span class="p">?</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="n">target</span><span class="p">.</span><span class="nf">isNullOrBlank</span><span class="p">())</span> <span class="k">return</span> <span class="k">null</span> <span class="kd">val</span> <span class="py">taskStatus</span> <span class="p">=</span> <span class="nc">TaskStatus</span><span class="p">.</span><span class="nf">getFrom</span><span class="p">(</span><span class="n">target</span><span class="p">)</span> <span class="o">?:</span> <span class="k">return</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">Error</span><span class="p">(</span><span class="s">"Unknown status=$target"</span><span class="p">)</span> <span class="k">return</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">MoveTaskTo</span><span class="p">(</span><span class="n">taskStatus</span><span class="p">)</span> <span class="p">}</span> <span class="p">}</span> <span class="n">data</span> <span class="kd">object</span> <span class="nc">Assign</span><span class="p">:</span> <span class="nc">CommandVerb</span> <span class="p">{</span> <span class="k">override</span> <span class="k">fun</span> <span class="nf">toVimAction</span><span class="p">(</span><span class="n">target</span><span class="p">:</span> <span class="nc">String</span><span class="p">?,</span> <span class="n">args</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Arg</span><span class="p">&gt;):</span> <span class="nc">VimAction</span><span class="p">?</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="n">target</span><span class="p">.</span><span class="nf">isNullOrBlank</span><span class="p">())</span> <span class="k">return</span> <span class="k">null</span> <span class="k">return</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">AssignTo</span><span class="p">(</span><span class="n">target</span><span class="p">.</span><span class="nf">lowercase</span><span class="p">())</span> <span class="p">}</span> <span class="p">}</span> <span class="n">data</span> <span class="kd">object</span> <span class="nc">Help</span><span class="p">:</span> <span class="nc">CommandVerb</span> <span class="p">{</span> <span class="k">override</span> <span class="k">fun</span> <span class="nf">toVimAction</span><span class="p">(</span><span class="n">target</span><span class="p">:</span> <span class="nc">String</span><span class="p">?,</span> <span class="n">args</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Arg</span><span class="p">&gt;):</span> <span class="nc">VimAction</span> <span class="p">{</span> <span class="k">return</span> <span class="nc">VimAction</span><span class="p">.</span><span class="nc">ShowHelp</span> <span class="p">}</span> <span class="p">}</span> <span class="k">companion</span> <span class="k">object</span> <span class="nc">Companion</span> <span class="p">{</span> <span class="k">fun</span> <span class="nf">getFrom</span><span class="p">(</span><span class="n">verbName</span><span class="p">:</span> <span class="nc">String</span><span class="p">):</span> <span class="nc">CommandVerb</span><span class="p">?</span> <span class="p">{</span> <span class="k">return</span> <span class="k">when</span> <span class="p">(</span><span class="n">verbName</span><span class="p">.</span><span class="nf">lowercase</span><span class="p">())</span> <span class="p">{</span> <span class="s">"status"</span> <span class="p">-&gt;</span> <span class="nc">Status</span> <span class="s">"assign"</span> <span class="p">-&gt;</span> <span class="nc">Assign</span> <span class="s">"help"</span> <span class="p">-&gt;</span> <span class="nc">Help</span> <span class="k">else</span> <span class="p">-&gt;</span> <span class="k">null</span> <span class="p">}</span> <span class="p">}</span> <span class="p">}</span> <span class="p">}</span> </code></pre> </div> <p>Using <code>regex</code> we parse the command, and from it we get Command Verb like so<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight kotlin"><code><span class="kd">val</span> <span class="py">verb</span> <span class="p">=</span> <span class="nf">getVerbFromCmd</span><span class="p">(</span><span class="n">cmd</span><span class="p">)</span> <span class="nc">CommandVerb</span><span class="p">.</span><span class="nf">getFrom</span><span class="p">(</span><span class="n">verb</span><span class="p">)</span><span class="o">?.</span><span class="nf">toVimAction</span><span class="p">(</span><span class="n">target</span><span class="p">,</span> <span class="n">args</span><span class="p">)</span> </code></pre> </div> <p>We need to care about edge cases like:</p> <ul> <li>What happens if verb is invalid</li> <li>What happens if target is invalid and so on</li> </ul> <h3> Creating The Engine </h3> <p>Since our mode parsers are ready now we can start develop the engine</p> <p>Our engine needs <code>NormalModeParser</code> and <code>CommandModeParser</code> as params, the engine also need to know the current <code>vim mode</code> to be able to know where to send keystrokes<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight kotlin"><code><span class="kd">class</span> <span class="nc">VimEngine</span><span class="p">(</span> <span class="k">private</span> <span class="kd">val</span> <span class="py">normalModeParser</span><span class="p">:</span> <span class="nc">ModeParser</span><span class="p">,</span> <span class="k">private</span> <span class="kd">val</span> <span class="py">commandModeParser</span><span class="p">:</span> <span class="nc">ModeParser</span><span class="p">,</span> <span class="p">)</span> <span class="p">{</span> <span class="kd">val</span> <span class="py">mode</span><span class="p">:</span> <span class="nc">VimMode</span> <span class="p">=</span> <span class="c1">// something</span> <span class="k">private</span> <span class="kd">val</span> <span class="py">modeToParser</span> <span class="p">=</span> <span class="nf">mapOf</span><span class="p">(</span> <span class="nc">VimMode</span><span class="p">.</span><span class="nc">Normal</span> <span class="n">to</span> <span class="n">normalModeParser</span><span class="p">,</span> <span class="nc">VimMode</span><span class="p">.</span><span class="nc">Command</span> <span class="n">to</span> <span class="n">commandModeParser</span><span class="p">,</span> <span class="p">)</span> <span class="k">fun</span> <span class="nf">handleKey</span><span class="p">(</span><span class="n">key</span><span class="p">:</span> <span class="nc">VimKey</span><span class="p">)</span> <span class="p">{</span> <span class="kd">val</span> <span class="py">parser</span> <span class="p">=</span> <span class="n">modeToParser</span><span class="p">[</span><span class="n">mode</span><span class="p">.</span><span class="n">value</span><span class="p">]</span> <span class="kd">val</span> <span class="py">vimAction</span> <span class="p">=</span> <span class="n">parser</span><span class="o">?.</span><span class="nf">parse</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="c1">// handle vimAction</span> <span class="p">}</span> <span class="p">}</span> </code></pre> </div> <p>But wait, how should we change vim mode? and how is responsible for that.</p> <p>What makes most sense is that the engine is the one holding vimMode and exposing that as a flow. I decided to make the parser return vim mode change, this way <code>VimAction</code> would only represent an action to be made later by ui and <code>vim mode</code> change is only meant for the <code>VimEngine</code> to see</p> <p>so, I updated mode parser function to return <code>ParserReult</code><br> </p> <div class="highlight js-code-highlight"> <pre class="highlight kotlin"><code> <span class="kd">data class</span> <span class="nc">ParseResult</span><span class="p">(</span> <span class="kd">val</span> <span class="py">action</span><span class="p">:</span> <span class="nc">VimAction</span><span class="p">?</span> <span class="p">=</span> <span class="k">null</span><span class="p">,</span> <span class="kd">val</span> <span class="py">nextMode</span><span class="p">:</span> <span class="nc">VimMode</span><span class="p">?</span> <span class="p">=</span> <span class="k">null</span> <span class="p">)</span> <span class="k">fun</span> <span class="nf">handle</span><span class="p">(</span><span class="n">c</span><span class="p">:</span> <span class="nc">Char</span><span class="p">):</span> <span class="nc">ParseResult</span> <span class="p">{</span> <span class="c1">// ...</span> </code></pre> </div> <p>then <code>handleKey</code> function in <code>VimEngine</code> becomes as follows<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight kotlin"><code><span class="k">fun</span> <span class="nf">handleKey</span><span class="p">(</span><span class="n">key</span><span class="p">:</span> <span class="nc">VimKey</span><span class="p">)</span> <span class="p">{</span> <span class="kd">val</span> <span class="py">parser</span> <span class="p">=</span> <span class="n">modeToParser</span><span class="p">[</span><span class="n">mode</span><span class="p">.</span><span class="n">value</span><span class="p">]</span> <span class="kd">val</span> <span class="py">parseResult</span> <span class="p">=</span> <span class="n">parser</span><span class="o">?.</span><span class="nf">parse</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="n">parseResult</span><span class="o">?.</span><span class="n">nextMode</span><span class="o">?.</span><span class="nf">let</span> <span class="p">{</span> <span class="n">nextMode</span> <span class="p">-&gt;</span> <span class="n">scope</span><span class="p">.</span><span class="nf">launch</span> <span class="p">{</span> <span class="n">_mode</span><span class="p">.</span><span class="nf">emit</span><span class="p">(</span><span class="n">nextMode</span><span class="p">)</span> <span class="p">}</span> <span class="p">}</span> <span class="n">parseResult</span><span class="o">?.</span><span class="n">action</span><span class="o">?.</span><span class="nf">let</span> <span class="p">{</span> <span class="n">action</span> <span class="p">-&gt;</span> <span class="n">scope</span><span class="p">.</span><span class="nf">launch</span> <span class="p">{</span> <span class="nf">emit</span><span class="p">(</span><span class="n">action</span><span class="p">)</span> <span class="p">}</span> <span class="p">}</span> <span class="p">}</span> </code></pre> </div> <p>What I have currently:</p> <ul> <li>User hit keys on keyboard → they get handled by <code>NormalModeParser</code> </li> <li>When user hit <code>:</code> <code>NormalModeParser</code> assign <code>nextMode=VimMode.Command</code> in <code>ParseResult</code> so mode switches</li> <li>Next keystrokes will be handled by <code>CommandModeParser</code> </li> </ul> <p>So far so good.</p> <p>Later I can add </p> <ul> <li>more parsers, </li> <li>more keybindings to <code>normal mode</code> </li> <li>and more command to <code>command mode</code> easily </li> </ul> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code> ┌─────────────┐ │ Vim Engine │ └──────┬──────┘ │ ┌──────┴──────────┐ ▼ ▼ ┌──────────────┐ ┌──────────────┐ │ Normal │ │ Command │ │ Mode │ │ Mode │ │ Parser │ │ Parser │ └──────────────┘ └──────────────┘ </code></pre> </div> <h3> Hook To UI </h3> <p>Once the engine was stable, the next challenge was integrating it cleanly with the Compose UI layer.</p> <p>I am using compose and handling keystrokes is straightforward. I listen to <code>onKeyEventModifier</code>, get the key, and sent it to <code>ViewModel</code> to be sent later to <code>VimEngine</code>.</p> <p>I had a screen with a vertical list of tasks and I was navigating through them in vim keybinding, switching between modes, etc.. all is working</p> <p>But then I wanted to show a task details, side-by-side with task list screen. The standard these days in compose is using multi-pane view. On the left i have tasks list, and on the right I have task details view. I did this and it looked nice.<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>┌─────────────────────────────────────────────────┐ │ │ │ ┌───────────────┐ ┌───────────────────┐ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ tasks list │ │ task details │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └───────────────┘ └───────────────────┘ │ │ │ └─────────────────────────────────────────────────┘ </code></pre> </div> <h2> Why I Chose Multiple Vim Engines per Pane </h2> <p>When I introduced the multi-pane layout (tasks list + task details), an interesting architectural question appeared:</p> <blockquote> <p>Who owns the keyboard behavior when multiple panes are visible?</p> </blockquote> <p>Each pane had <strong>very different interaction semantics</strong>:</p> <ul> <li>Task list → navigation heavy (<code>j</code>, <code>k</code>, <code>gg</code>, filtering…)</li> <li>Task details → editing, actions, different commands</li> <li>Future panes → unknown behaviors</li> </ul> <p>I considered three approaches.</p> <h3> Option 1 — Dynamic Keymap Switching </h3> <p>Switch keybindings whenever focus changes.</p> <p>Pros: </p> <ul> <li>Single engine instant</li> <li>Simple mental model initially Cons:</li> <li>Keymap mutation at runtime</li> <li>Harder to reason about state</li> <li>Become fragile as number of panes grows</li> </ul> <p>This felt convenient short-term but risky long-term</p> <h3> Option 2 — Swap Parsers Inside One Engine </h3> <p>Keep one engine but replace it’s parsers based on focused pane.</p> <p>Pros:</p> <ul> <li>Still one engine</li> <li>Some separation of behavior (different parsers for different focus panes) Cons:</li> <li>Engine becomes focus-aware. This prevent it of being re-used in another projects</li> <li>Parser lifecycle becomes harder to track</li> <li>Increased coupling between UI and engine</li> </ul> <p>This improved separation slightly but still mixed responsibilities.</p> <h3> Option 3 — Multiple Vim Engines (Chosen) </h3> <p>Each focusable pane owns its own VimEngine instance.</p> <p>The ViewModel simply routes keystrokes to <strong>currently focused pane’s engine</strong></p> <p>Pros:</p> <ul> <li>Strong isolation between panes</li> <li>Each pane can evolve independently</li> <li>No runtime mutations of keymaps</li> <li>Simpler mental model per engine</li> <li>Future-proof for more panes</li> <li>Enable parsers sharing when desired Cons:</li> <li>More engine instances in memory</li> <li>Slightly more wiring in ViewModel</li> </ul> <p>For this applications the tradeoff was clearly worth it.</p> <h3> The Key Design Principle </h3> <p>The decision was guided by one rule:</p> <blockquote> <p>Keyboard behavior is contextual UI state, not global application state.</p> </blockquote> <p>By giving each pane its own engine:</p> <ul> <li>focus becomes the only routing concern</li> <li>engines remain pure and predictable</li> <li>adding new panes does not increase complexity of existing ones</li> </ul> <p>In practice, this made the system <strong>much easier to extend</strong> than the single-engine approaches.</p> <h3> Why This Matters for Future Growth </h3> <p>This design unlocks several things almost for free:</p> <ul> <li>Different Vim capabilities per pane</li> <li>Experimental keymaps in isolated areas</li> <li>Plugin-like future architecture</li> <li>Potential extraction of the Vim engine as a reusable library</li> </ul> <p>Most importantly, it keeps the architecture honest: each UI surface owns its own interaction model.</p> <h3> Small But Important Optimization </h3> <p>Even though I use multiple engines, parsers themselves can still be shared when behavior overlaps. This avoids unnecessary duplication while preserving isolation where it matters.<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code> Key Press │ ▼ ┌────────────────┐ │ ViewModel │ │ (focus aware) │ └──────┬─────────┘ │ ┌────────────┴────────────┐ │ │ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ │ Tasks List Pane │ │ Task Details │ │ VimEngine │ │ VimEngine │ └────────┬────────┘ └────────┬────────┘ │ │ ▼ ▼ Normal / Command Normal / Command Parsers Parsers </code></pre> </div> <p>The complete flow looks like this<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>User presses key │ ▼ ┌──────────────────────┐ │ Compose UI │ │ onKeyEvent(...) │ └──────────┬───────────┘ │ ▼ ┌──────────────────────┐ │ ViewModel │ │ (focus-aware router) │ └──────────┬───────────┘ │ routes by focus ▼ ┌──────────────────────┐ │ VimEngine │ │ (mode-aware parse) │ └──────────┬───────────┘ │ emits ▼ ┌──────────────────────┐ │ VimAction │ └──────────┬───────────┘ │ mapped to ▼ ┌──────────────────────┐ │ ScreenIntent │ └──────────┬───────────┘ │ handled by ▼ ┌──────────────────────┐ │ ScreenInteractor │ │ (business logic) │ └──────────┬───────────┘ │ produces ▼ ┌──────────────────────┐ │ Reducer (MVI) │ │ uses currentState │ └──────────┬───────────┘ │ emits ▼ ┌──────────────────────┐ │ New State │ └──────────┬───────────┘ │ ▼ Compose UI recomposes </code></pre> </div> <h2> UI </h2> <h3> Switching Focus </h3> <p>To be able for the <code>viewModel</code> to know which pane is focused I created a state for it and saved it in <code>viewModel</code>. Then based on emitted vim actions I would know which pane is focused</p> <p>For example:</p> <ul> <li>when user click <code>enter</code> on a task in <strong>tasks list</strong> pane → switch focus to <strong>task details</strong> pane</li> <li>when user click <code>esc</code> on <strong>task details</strong> pane → switch focus back to <strong>tasks list</strong> pane</li> <li>I even went step further and added vim like keybinding for this in <code>NormalModeParser</code> <ul> <li> <code>ctrl-l</code>: switch focus to pane on the right (in this case <strong>task details</strong>)</li> <li> <code>ctrl-h</code> switch focus to pane on the left (in this case <strong>tasks list</strong>)</li> </ul> </li> </ul> <blockquote> <p>Of course using mouse clicks here would also work and switch focus properly</p> <p>Reminder: this is not a vim-only app, but vim based so mouse still works as expected (screen touches as well for mobile devices)</p> </blockquote> <h3> UI Features </h3> <ul> <li> <strong>Tasks List</strong>: auto scroll when user hit <code>j</code>, <code>k</code>, <code>gg</code>, <code>G</code> </li> <li>Animated task status update, with loading progress while it’s getting updated</li> <li>Stacked <strong>Notification</strong> system: show <code>info</code>, <code>error</code>, <code>warning</code> notifications at the top-right corner of the app, with auto-disappearing after 3 seconds</li> <li>Highlight focused pane</li> <li>Popup to show all available keybindings and what each do</li> </ul> <h3> More Vim Features </h3> <p>Here is a list of vim feature I also supported</p> <ul> <li> <strong>Repeatable actions</strong> Repeating last command by clicking on <code>.</code> </li> <li>In <strong>Command Mode</strong>: click <code>arrow-up/arrow-down</code> it would show previous/forward commands executed</li> <li>Each parser has it’s own buffer</li> <li> <strong>Vim Engine</strong>: Expose buffer for the currently working parser, show buffer content on UI</li> <li> <strong>NormalModeParser</strong>: <ul> <li>Created <strong>default keybinding</strong> </li> <li> <strong>Extra keybinding</strong>: to support configurable keybindings later</li> </ul> </li> </ul> <h2> Data Layer </h2> <p>This app is intended to be used offline. So I need a local data source and remote data source (Jira). They both expose my domain level models. </p> <ul> <li>Remote Data Source (interface) <ul> <li> <strong>Real Implementation</strong>: abstract way Jira models, and only return back my domain level models</li> <li> <strong>Fake Implementation</strong> for testing </li> </ul> </li> <li>Local Data Source (interface) <ul> <li> <strong>In-Memory Implementation</strong>: store and cache data to memory </li> <li> <strong>Db Implementation</strong> (to be done) save into DB. This is needed for <strong>offline</strong> mode support</li> </ul> </li> </ul> <h2> Claude Code </h2> <p>It’s 2026, not using AI in the development workflow would be a missed opportunity.</p> <p>I used LLM tooling (Claude Code) strategically to accelerate implementation while keeping full architectural ownership and code review responsibility.</p> <p>My rule was simple:</p> <blockquote> <p>AI can generate — but I design, verify, and own the system.</p> </blockquote> <h3> What AI helped the most </h3> <h4> UI Scaffolding </h4> <p>Since the early focus of the project was the interaction model rather than visual polish, I used Claude to scaffold several UI components.</p> <p>With well-scoped prompts and clear context, most components were generated correctly in one pass. This allowed me to:</p> <ul> <li>move faster in early iterations</li> <li>avoid spending time on repetitive Compose boilerplate</li> <li>keep focus on the Vim engine and state flow</li> </ul> <p>As the product matures, I expect to take more manual control over UX refinement.</p> <h4> API Layer </h4> <p>The Jira integration layer is something I’ve implemented many times professionally, so it was a good candidate for delegation.</p> <p>I provided Claude with:</p> <ul> <li>Jira API documentation</li> <li>my project structure conventions</li> <li>interface contracts (real + fake implementations)</li> <li>error-handling expectations</li> <li>domain model mappings</li> </ul> <p>Because the constraints were explicit, the generated code was:</p> <ul> <li>Clean</li> <li>Testable</li> <li>Idiomatic</li> <li>and required only light review adjustments</li> </ul> <p>This is exactly the kind of work where AI currently provides the most leverage.</p> <h4> Unit Testing </h4> <p>The Vim engine and parsers have many edge cases, and comprehensive unit testing is essential but time-consuming.</p> <p>My workflow was:</p> <ol> <li>I defined the test scenarios</li> <li>Claude generated the test implementations</li> <li>I reviewed and refined them</li> <li>GitHub Actions enforce them on every PR</li> </ol> <p>This gave me broad test coverage quickly while maintaining confidence in correctness.</p> <h4> What AI Did <em>Not</em> Own </h4> <p>The following remained fully manual:</p> <ul> <li>overall architecture</li> <li>Vim engine design</li> <li>mode system</li> <li>state flow (MVI)</li> <li>focus routing model</li> <li>concurrency decisions</li> </ul> <p>AI accelerated the build — but the system design decisions remained human-driven.</p> <h4> Takeaway </h4> <p>Used carelessly, AI can produce fragile systems.</p> <p>Used deliberately, it becomes a powerful force multiplier.</p> <p>In this project, the goal was never to replace engineering judgment — only to remove unnecessary friction from the implementation process.</p> <h2> Things To Improve </h2> <p>This is still the first version of the app. The core interaction model is working well, but several areas need to mature before this could be considered production-ready.</p> <p><strong>High priority</strong></p> <ul> <li><p><strong>Proper Jira API authentication</strong><br><br> Currently the app uses a simple API token. Supporting OAuth and improving token handling will be required for real-world usage.</p></li> <li><p><strong>Offline mode support</strong><br><br> The data layer is designed for it, but the database implementation and sync strategy still need to be completed.</p></li> <li><p><strong>Smarter cache invalidation</strong><br><br> Right now caching is basic. As usage grows, I will need more deliberate invalidation and refresh strategies to avoid stale task data.</p></li> </ul> <p><strong>Medium priority</strong></p> <ul> <li><p><strong>Extract VimEngine as a reusable library</strong><br><br> The engine is already mostly decoupled. With some cleanup it could become a standalone module usable in other projects.</p></li> <li><p><strong>More Vim motions and text objects</strong><br><br> The current implementation focuses on navigation and commands. Expanding motion coverage will improve muscle-memory compatibility for heavy Vim users.</p></li> </ul> <p><strong>Longer-term explorations</strong></p> <ul> <li><p><strong>Performance tuning under heavy key input</strong><br><br> As the number of panes and commands grows, I want to measure and optimize keystroke latency and buffering behavior.</p></li> <li><p><strong>Plugin-style extensibility</strong><br><br> The multi-engine design opens the door for pane-specific extensions. I’m interested in exploring how far this model can scale.</p></li> </ul> <h2> Final thoughts </h2> <p>What started as a small experiment has quietly become part of my daily workflow. I now use it daily at work on daily standup (I am the scrum master, I can do that 😁)</p> <p>Simple operations — like filtering tasks or moving an issue from <code>todo</code> to <code>done</code> are now muscle memory. For example, <code>md</code> (move to done) is often faster than reaching for the mouse and navigating multiple menus.</p> <p>Interestingly, my teammates initially assumed I was using some existing tool rather than something custom-built. That reaction alone was a strong signal that the interaction model is heading in the right direction.</p> <p>There is still plenty of work ahead, but the core bet is already paying off: <strong>when keyboard interaction is treated as a first-class architectural concern, the entire experience changes.</strong></p> <p>The goal was never to replace Jira, only to make working with it finally feel fast.</p> kotlin vim productivity architecture I Built a Tool to Track My Open Source Contributions MABD Mon, 22 Dec 2025 06:41:06 +0000 https://dev.to/mabd_dev/i-built-a-tool-to-track-my-open-source-contributions-42hc https://dev.to/mabd_dev/i-built-a-tool-to-track-my-open-source-contributions-42hc <p>Github contributions graph is great at showing activity, but it does not answer the question: <strong>what open source projects I have contributed to</strong>.</p> <p>I wanted to display open source projects I have contributed to on my personal website. I can do this manually. However, this can get annoying and add extra thing to remember. <br> I want to show projects I contributed to, how many PR’s merged, lines of code contributed. Github does not surface this easily, so i built a tool to do so.</p> <h2> The Problem </h2> <p>If you contribute to external repositories (projects you don’t own) Github buries this info. You can get it manually by searching your PR’s, but their is no API endpoint that says “give me all this user’s contributions to external repositories”</p> <p>I wanted:</p> <ul> <li>List of external projects contributed to</li> <li>Number of merged PR’s per project</li> <li>Commit count and number of lines added/removed (per project)</li> <li>JSON output I can feed to my website</li> </ul> <p>So I created <a href="proxy.php?url=https://github.com/mabd-dev/gh-oss-stats" rel="noopener noreferrer">gh-oss-stats</a></p> <h2> The Approach </h2> <p>The core insight is to use Github’s search query</p> <blockquote> <p>author:USERNAME type:pr is:merged -user:USERNAME</p> </blockquote> <p>This find all pull requets:</p> <ul> <li>Authored by you (<code>author:USERNAME</code>)</li> <li>That are PR’s not issues (<code>type:pr</code>)</li> <li>That are merged (<code>is:merged</code>)</li> <li>For repos you <strong>don’t</strong> own (<code>-user:USERNAME</code>) That's your OSS contribution history in one query.</li> </ul> <p>Request looks like this:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight http"><code><span class="err">https://api.github.com/search/issues?q=author:mabd-dev+type:pr+is:merged+-user:mabd-dev </span></code></pre> </div> <p>output looks like this:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight json"><code><span class="p">{</span><span class="w"> </span><span class="nl">"total_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span><span class="w"> </span><span class="nl">"incomplete_results"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nl">"items"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://api.github.com/repos/qamarelsafadi/JetpackComposeTracker/issues/9"</span><span class="p">,</span><span class="w"> </span><span class="nl">"repository_url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://api.github.com/repos/qamarelsafadi/JetpackComposeTracker"</span><span class="p">,</span><span class="w"> </span><span class="nl">"labels_url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://api.github.com/repos/qamarelsafadi/JetpackComposeTracker/issues/9/labels{/name}"</span><span class="p">,</span><span class="w"> </span><span class="nl">"comments_url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://api.github.com/repos/qamarelsafadi/JetpackComposeTracker/issues/9/comments"</span><span class="p">,</span><span class="w"> </span><span class="nl">"events_url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://api.github.com/repos/qamarelsafadi/JetpackComposeTracker/issues/9/events"</span><span class="p">,</span><span class="w"> </span><span class="nl">"html_url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://github.com/qamarelsafadi/JetpackComposeTracker/pull/9"</span><span class="p">,</span><span class="w"> </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="mi">3204496021</span><span class="p">,</span><span class="w"> </span><span class="nl">"node_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"PR_kwDONQBujs6diLmP"</span><span class="p">,</span><span class="w"> </span><span class="nl">"number"</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="p">,</span><span class="w"> </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"🔧 Refactor: Add Global Theme Support for UI Customization"</span><span class="p">,</span><span class="w"> </span><span class="nl">"user"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="err">...</span><span class="p">},</span><span class="w"> </span><span class="nl">"labels"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="err">...</span><span class="p">],</span><span class="w"> </span><span class="nl">"state"</span><span class="p">:</span><span class="w"> </span><span class="s2">"closed"</span><span class="p">,</span><span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="err">...</span><span class="w"> </span><span class="err">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span></code></pre> </div> <p>From there, it's a matter of:</p> <ol> <li>Fetching PR details (commits, additions, deletions)</li> <li>Enriching with repo metadata (stars, description)</li> <li>Aggregating into useful statistics</li> </ol> <h2> Architecture Decision: Library First </h2> <p>I built this as a Go library with a CLI wrapper, not just a CLI tool. The core logic lives in an importable package:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight go"><code><span class="k">import</span> <span class="s">"github.com/mabd-dev/gh-oss-stats/pkg/ossstats"</span> <span class="n">client</span> <span class="o">:=</span> <span class="n">ossstats</span><span class="o">.</span><span class="n">New</span><span class="p">(</span> <span class="n">ossstats</span><span class="o">.</span><span class="n">WithToken</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"GITHUB_TOKEN"</span><span class="p">)),</span> <span class="n">ossstats</span><span class="o">.</span><span class="n">WithLOC</span><span class="p">(</span><span class="no">true</span><span class="p">),</span> <span class="n">LOC</span><span class="o">:</span> <span class="n">lines</span> <span class="n">of</span> <span class="n">code</span> <span class="p">)</span> <span class="n">stats</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">client</span><span class="o">.</span><span class="n">GetContributions</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="s">"mabd-dev"</span><span class="p">)</span> </code></pre> </div> <p>This means I can use the same code in:</p> <ul> <li>The CLI tool (for local use)</li> <li>GitHub Actions (automated updates)</li> <li>A future badge service (SVG generation)</li> <li>Anywhere else I need this data The CLI is just a thin wrapper that parses flags and calls the library.</li> </ul> <h3> Handling GitHub's Rate Limits </h3> <p>GitHub's API has limits: 5,000 requests/hour for authenticated users, but only 60 requests/hour for the Search API. For someone with many contributions, you can burn through this quickly.</p> <p>The tool implements:</p> <ul> <li>Exponential backoff on rate limit errors</li> <li>2-second delays between search API calls</li> <li>Controlled concurrency (5 parallel requests for PR details)</li> <li>Partial results if rate limited mid-fetch</li> </ul> <h2> The Output </h2> <p>Running the tool produces JSON like this:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight json"><code><span class="p">{</span><span class="w"> </span><span class="nl">"username"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mabd-dev"</span><span class="p">,</span><span class="w"> </span><span class="nl">"generatedAt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2025-12-21T06:46:57.823990311Z"</span><span class="p">,</span><span class="w"> </span><span class="nl">"summary"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"totalProjects"</span><span class="p">:</span><span class="w"> </span><span class="mi">7</span><span class="p">,</span><span class="w"> </span><span class="nl">"totalPRsMerged"</span><span class="p">:</span><span class="w"> </span><span class="mi">17</span><span class="p">,</span><span class="w"> </span><span class="nl">"totalCommits"</span><span class="p">:</span><span class="w"> </span><span class="mi">58</span><span class="p">,</span><span class="w"> </span><span class="nl">"totalAdditions"</span><span class="p">:</span><span class="w"> </span><span class="mi">1270</span><span class="p">,</span><span class="w"> </span><span class="nl">"totalDeletions"</span><span class="p">:</span><span class="w"> </span><span class="mi">594</span><span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="nl">"contributions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"repo"</span><span class="p">:</span><span class="w"> </span><span class="s2">"qamarelsafadi/JetpackComposeTracker"</span><span class="p">,</span><span class="w"> </span><span class="nl">"owner"</span><span class="p">:</span><span class="w"> </span><span class="s2">"qamarelsafadi"</span><span class="p">,</span><span class="w"> </span><span class="nl">"repoName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"JetpackComposeTracker"</span><span class="p">,</span><span class="w"> </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"This is a tool to track you recomposition state in real-time !"</span><span class="p">,</span><span class="w"> </span><span class="nl">"repoURL"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://github.com/qamarelsafadi/JetpackComposeTracker"</span><span class="p">,</span><span class="w"> </span><span class="nl">"stars"</span><span class="p">:</span><span class="w"> </span><span class="mi">94</span><span class="p">,</span><span class="w"> </span><span class="nl">"prsMerged"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="nl">"commits"</span><span class="p">:</span><span class="w"> </span><span class="mi">14</span><span class="p">,</span><span class="w"> </span><span class="nl">"additions"</span><span class="p">:</span><span class="w"> </span><span class="mi">181</span><span class="p">,</span><span class="w"> </span><span class="nl">"deletions"</span><span class="p">:</span><span class="w"> </span><span class="mi">78</span><span class="p">,</span><span class="w"> </span><span class="nl">"firstContribution"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2025-06-14T20:55:24Z"</span><span class="p">,</span><span class="w"> </span><span class="nl">"lastContribution"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2025-07-21T21:39:53Z"</span><span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="err">...</span><span class="w"> </span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w"> </span></code></pre> </div> <p>This feeds directly into my website's contributions section.</p> <h2> Using It </h2> <h3> Installation </h3> <div class="highlight js-code-highlight"> <pre class="highlight shell"><code>go <span class="nb">install </span>github.com/mabd-dev/gh-oss-stats/cmd/gh-oss-stats@latest </code></pre> </div> <h3> Basic Usage </h3> <div class="highlight js-code-highlight"> <pre class="highlight shell"><code><span class="c"># Set your GitHub token</span> <span class="nb">export </span><span class="nv">GITHUB_TOKEN</span><span class="o">=</span>ghp_xxxxxxxxxxxx <span class="c"># Run it</span> gh-oss-stats <span class="nt">--user</span> YOUR_USERNAME <span class="c"># Save to file</span> gh-oss-stats <span class="nt">--user</span> YOUR_USERNAME <span class="nt">-o</span> contributions.json </code></pre> </div> <h3> Automating with GitHub Actions </h3> <p>I run this weekly via GitHub Actions to keep my website updated automatically:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight yaml"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Update OSS Contributions</span> <span class="na">on</span><span class="pi">:</span> <span class="na">schedule</span><span class="pi">:</span> <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0</span><span class="nv"> </span><span class="s">0</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">0'</span> <span class="c1"># Weekly on Sunday</span> <span class="na">workflow_dispatch</span><span class="pi">:</span> <span class="c1"># Manual trigger</span> <span class="na">permissions</span><span class="pi">:</span> <span class="na">contents</span><span class="pi">:</span> <span class="s">write</span> <span class="na">jobs</span><span class="pi">:</span> <span class="na">update-stats</span><span class="pi">:</span> <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span> <span class="na">steps</span><span class="pi">:</span> <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span> <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-go@v5</span> <span class="na">with</span><span class="pi">:</span> <span class="na">go-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">1.25'</span> <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install gh-oss-stats</span> <span class="na">run</span><span class="pi">:</span> <span class="s">go install github.com/mabd-dev/gh-oss-stats/cmd/gh-oss-stats@latest</span> <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Fetch contributions</span> <span class="na">env</span><span class="pi">:</span> <span class="na">GITHUB_TOKEN</span><span class="pi">:</span> <span class="s">${{ secrets.GH_OSS_TOKEN }}</span> <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span> <span class="s">gh-oss-stats \</span> <span class="s">--user YOUR_USERNAME \</span> <span class="s">--exclude-orgs="your-org" \</span> <span class="s">-o data/contributions.json</span> <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Commit changes</span> <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span> <span class="s">git config user.name "github-actions[bot]"</span> <span class="s">git config user.email "github-actions[bot]@users.noreply.github.com"</span> <span class="s">git add data/contributions.json</span> <span class="s">if ! git diff --staged --quiet; then</span> <span class="s">git commit -m "Update OSS contributions"</span> <span class="s">git push</span> <span class="s">fi</span> </code></pre> </div> <p>Now my website always has fresh data without any manual work.</p> <h2> Displaying on My Website </h2> <p>On <a href="proxy.php?url=https://mabd.dev" rel="noopener noreferrer">mabd.dev</a>, I read the JSON file and render it. The exact implementation depends on your stack, but the data structure makes it straightforward:</p> <ul> <li>Loop through <code>contributions</code> array</li> <li>Display repo name, stars, PR count</li> <li>Show totals from <code>summary</code> </li> <li>Link to the actual repos</li> </ul> <p>The JSON is the contract — however you want to display it is up to you.</p> <h2> What I Learned </h2> <p><strong>GitHub's Search API is powerful but quirky.</strong> The <code>-user:</code> exclusion syntax does not exclude repos you own on your organization. I had to do custom logic to detect that.</p> <p><strong>Library-first design pays off.</strong> Building the core as an importable package meant the CLI came together in under an hour. It also means future tools (like a badge service) can reuse 100% of the logic.</p> <h2> What's Next </h2> <p>I'm planning to build a companion service <strong>gh-oss-badge</strong> that generates SVG badges you can embed in your GitHub profile README:</p> <p>markdown<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight markdown"><code><span class="p">![</span><span class="nv">OSS Stats</span><span class="p">](</span><span class="sx">https://oss-badge.example.com/mabd-dev.svg</span><span class="p">)</span> </code></pre> </div> <p>Same data, different presentation. The library-first architecture means this service will just import <code>gh-oss-stats/pkg/ossstats</code> and add an HTTP layer on top.</p> <p>If you want to track your own OSS contributions, give <a href="proxy.php?url=https://github.com/mabd-dev/gh-oss-stats" rel="noopener noreferrer">gh-oss-stats</a> a try. It's open source (naturally), and contributions are welcome</p> <h3> Resources </h3> <ul> <li> <strong>Github api docs:</strong> <a href="proxy.php?url=https://docs.github.com/en/rest?apiVersion=2022-11-28" rel="noopener noreferrer">https://docs.github.com/en/rest?apiVersion=2022-11-28</a> </li> <li> <strong>Github api rate limit:</strong> <a href="proxy.php?url=https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28" rel="noopener noreferrer">https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28</a> </li> <li> <strong>Authenticating to rest api:</strong> <a href="proxy.php?url=https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api?apiVersion=2022-11-28" rel="noopener noreferrer">https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api?apiVersion=2022-11-28</a> </li> </ul> opensource github programming Search Engine from Scratch — Part 1: The Inverted Index MABD Sun, 14 Dec 2025 11:03:30 +0000 https://dev.to/mabd_dev/search-engine-from-scratch-part-1-the-inverted-index-243n https://dev.to/mabd_dev/search-engine-from-scratch-part-1-the-inverted-index-243n <p>I started something big — building a <strong>text-based search engine</strong> from scratch 🔍</p> <p><strong>Why this?</strong></p> <ul> <li>🧠 Zero experience in search engines = massive learning opportunity</li> <li>💡 We use search daily (browser, Spotlight, Windows Search) yet rarely think about how it works</li> <li>⚙️ Pure algorithmic challenge — no backends, no APIs, just data structures and efficiency</li> </ul> <p>The goal: a fast, pluggable search tool I can hook into other projects.</p> <h3> My current knowledge </h3> <p>As a developer, when I hear "search feature," the first thing that comes to mind is a simple algorithm:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>for all texts -&gt; find text that contains word (case insensitive) </code></pre> </div> <p>That's it. The trivial "contains" algorithm.</p> <p>This is usually good enough for a large portion of projects. But what happens when the data is huge, or the query is more than just a single word?<br> Hmmm, this is where it gets complicated, and from here, I had no idea what to do.</p> <h3> Information Retrieval </h3> <blockquote> <p>Information retrieval (IR) is finding material of an unstructured nature (usually text) that satisfies an information need from within large collections.</p> </blockquote> <p>Let me explain with an example. Say you have a list of 100 words and their meanings. When you want to find a word's meaning, you traverse the list until you find it. But what happens when the list grows to 100,000 words? There's no way you can traverse the whole list every single time.</p> <p>Those 100,000 words are <strong>unstructured</strong> and the collection is <strong>very large</strong>.</p> <p>To retrieve information efficiently, we need a better approach. As a developer, you probably already know one solution: <strong>sorting</strong>.</p> <p>Sort the words alphabetically and finding any word becomes trivial, even if the collection grows to 10 million entries.</p> <p>Search engines apply similar thinking: take a huge collection of data, then use algorithms and data structures to organize it for fast future lookups.</p> <h3> Search Engine V1 </h3> <p><strong>Objective</strong>: choose a folder on you machine and search for any word in it</p> <p>W’ll build our engine based on files, let’s call them <strong>documents</strong><br> </p> <div class="highlight js-code-highlight"> <pre class="highlight go"><code><span class="k">type</span> <span class="n">Document</span> <span class="k">struct</span> <span class="p">{</span> <span class="n">ID</span> <span class="kt">int</span> <span class="n">Path</span> <span class="kt">string</span> <span class="p">}</span> </code></pre> </div> <p>We want to search for words across a folder, so ideally our data structure would map each word to the list of files where it appears. This is called an <strong>inverted index</strong>, "inverted" because instead of mapping documents → words, we map words → documents.</p> <p>Each location where a word appears is called a <strong>posting</strong> (think of it as "posting" the document to that word's list):<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight go"><code><span class="k">type</span> <span class="n">Posting</span> <span class="k">struct</span> <span class="p">{</span> <span class="n">DocID</span> <span class="kt">int</span> <span class="p">}</span> <span class="k">type</span> <span class="n">Index</span> <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="n">Posting</span> </code></pre> </div> <p>Now we need to process all files in our folder and extract unique words. This process is called <strong>indexing</strong>.</p> <p>The algorithm is straightforward: for each file, split by whitespace, then trim leading and trailing spaces from each word.<br> But a problem appears — you end up with things like: <code>(IR)</code>, <code>Objective:</code>, <code>backends.</code>, <code>}</code></p> <p>So we need to remove symbols and punctuation to get clean words. Let's call these cleaned words <strong>tokens</strong>.</p> <p>Now we can build our index:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight go"><code><span class="k">func</span> <span class="n">indexDocument</span><span class="p">(</span><span class="n">doc</span> <span class="n">Document</span><span class="p">,</span> <span class="n">index</span> <span class="n">Index</span><span class="p">)</span> <span class="n">Index</span> <span class="p">{</span> <span class="n">fileContent</span> <span class="o">:=</span> <span class="n">getFileContent</span><span class="p">(</span><span class="n">doc</span><span class="o">.</span><span class="n">Path</span><span class="p">)</span> <span class="n">tokens</span> <span class="o">:=</span> <span class="n">tokenize</span><span class="p">(</span><span class="n">fileContent</span><span class="p">)</span> <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">token</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">tokens</span> <span class="p">{</span> <span class="n">index</span><span class="p">[</span><span class="n">token</span><span class="p">]</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">postings</span><span class="p">,</span> <span class="n">Posting</span><span class="p">{</span><span class="n">DocID</span><span class="o">:</span> <span class="n">doc</span><span class="o">.</span><span class="n">ID</span><span class="p">})</span> <span class="p">}</span> <span class="k">return</span> <span class="n">index</span> <span class="p">}</span> </code></pre> </div> <p>After indexing all files, we're ready for queries.</p> <h4> Querying </h4> <p>For now, let's keep it simple, searching for a single word:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight go"><code><span class="k">func</span> <span class="n">getPostings</span><span class="p">(</span><span class="n">token</span> <span class="kt">string</span><span class="p">)</span> <span class="p">[]</span><span class="n">Posting</span> <span class="p">{</span> <span class="k">return</span> <span class="n">index</span><span class="p">[</span><span class="n">token</span><span class="p">]</span> <span class="p">}</span> </code></pre> </div> <p>That's it! We get all documents where our token exists.</p> <p>This will get more exciting later when we track <em>positions</em> within each document where the query appears. Try implementing that yourself 🙂</p> <p>You can find the full code on <a href="proxy.php?url=https://github.com/mabd-dev/search-engine/tree/v1" rel="noopener noreferrer">github</a></p> softwaredevelopment buildinpublic opensource learning