Jujutsu/git integration

The editor now has a button to commit changes to a Jujutsu repository,
which is automatically created if it's not present.

A git remote is automatically set up using the URL template in
settings.py. I use this with Forgejo's create-on-push feature to
automatically create repositories on my Forgejo instance.
This commit is contained in:
Christian Lawson-Perfect 2025-02-10 10:05:00 +00:00
parent 500eb38774
commit d474a394f5
13 changed files with 913 additions and 376 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ public/
secret_key.txt
think_data/
Makefile
thinkserver/settings.py

View file

@ -127,3 +127,10 @@ class RunCommandForm(forms.ModelForm):
class Meta:
model = Think
fields = []
class GitCommitForm(forms.ModelForm):
message = forms.CharField()
class Meta:
model = Think
fields = []

85
thinks/jujutsu.py Normal file
View file

@ -0,0 +1,85 @@
import functools
import subprocess
from django.conf import settings
def ensure_jj(fn):
@functools.wraps(fn)
def ofn(self,*args,**kwargs):
self.init_jj()
return fn(self,*args,**kwargs)
return ofn
class JJController:
def __init__(self, think):
self.think = think
self.root = think.root
def run(self, cmd, **kwargs):
print("Run command",cmd)
res = subprocess.run(
cmd,
cwd=self.root,
encoding='utf8',
capture_output=True,
**kwargs
)
return res
def init_jj(self):
print("Init jj")
if not (self.root / '.jj').exists():
self.run(['jj','git','init'])
self.ignore_paths(['.make.*'])
git_url = settings.GIT_REPO_URL_TEMPLATE.format(name=self.think.slug)
self.run(['jj','git','remote','add','origin', git_url])
@ensure_jj
def status(self):
res = self.run(['jj','st'])
return res.stdout
def clean_paths(self, paths):
paths = [self.root / p for p in paths]
return [str(p.relative_to(self.root)) for p in paths if p.is_relative_to(self.root)]
@ensure_jj
def ignore_paths(self, paths):
paths = self.clean_paths(paths)
gitignore = self.root / '.gitignore'
if len(paths) == 0:
return
if gitignore.exists():
with open(gitignore) as f:
ignored = f.read().strip().split('\n')
ignored += [p for p in paths if p not in ignored]
else:
ignored = paths
with open(gitignore, 'w') as f:
f.write('\n'.join(ignored))
@ensure_jj
def remove_paths(self, paths):
paths = self.clean_paths(paths)
if len(paths) == 0:
return
return self.run(['git','rm'] + paths)
@ensure_jj
def add_paths(self, paths):
paths = self.clean_paths(paths)
if len(paths) == 0:
return
return self.run(['git','add'] + paths)
@ensure_jj
def commit(self, message):
res = self.run(['jj','describe','--stdin','--no-edit'], input=message)
if res.returncode == 0:
self.run(['jj','new'])
return res

View file

@ -3,6 +3,8 @@ from django.db import models
from django.urls import reverse
from django.utils.timezone import datetime, make_aware
from .jujutsu import JJController
THINKS_DIR = settings.THINKS_DIR
# Create your models here.
@ -21,6 +23,13 @@ class Think(models.Model):
def root(self):
return (THINKS_DIR / self.slug).resolve()
@property
def jj_controller(self):
return JJController(self)
def has_jj(self):
return (self.root / '.jj').exists()
def get_absolute_url(self):
return reverse('think', kwargs={'slug': self.slug})

View file

@ -2,6 +2,8 @@ import './code-editor.mjs';
export default async function init_app() {
const flags = JSON.parse(document.getElementById('think-editor-data').textContent);
const packages = await (await fetch('https://elm-package-list.think.somethingorotherwhatever.com/elm-packages.json')).json();
flags.elm_packages = packages;
flags.csrf_token = document.getElementById('csrftoken')?.textContent || '';
const app = Elm.App.init({node: document.body, flags});
@ -16,4 +18,9 @@ export default async function init_app() {
},10);
}
})
app.ports.show_modal.subscribe(id => {
console.log(id);
document.getElementById(id).showModal()
})
}

View file

@ -1,9 +1,24 @@
:root {
--spacing: 1em;
--quarter-spacing: calc(0.25 * var(--spacing));
--half-spacing: calc(0.5 * var(--spacing));
--double-spacing: calc(2 * var(--spacing));
--radius: 0.2em;
--editor-size: 50%;
--background: hsl(70,100%,95%);
--color: black;
--button-bg: #ddd;
}
@media (prefers-color-scheme: dark) {
body {
--background: hsl(70,100%,8%);
--color: white;
--button-bg: #333;
}
}
* {
@ -20,6 +35,9 @@ body {
margin: 0;
padding: var(--half-spacing);
background: var(--background);
color: var(--color);
& > header {
padding: var(--spacing);
& h1 {
@ -29,12 +47,6 @@ body {
}
@media (prefers-color-scheme: dark) {
body {
background: black;
color: white;
}
}
header {
& #think-controls {
@ -53,6 +65,56 @@ header {
}
button {
border: thin solid currentColor;
--button-background: var(--button-bg);
--highlight: white;
--highlight-amount: 0%;
--darken: black;
--darken-amount: 0%;
background-color:
color-mix(in oklab,
color-mix(in oklab,
var(--button-background),
var(--darken) var(--darken-amount)
),
var(--highlight) var(--highlight-amount)
);
&:focus {
--highlight-amount: 40%;
}
&:hover {
--highlight-amount: 60%;
}
&:active {
--highlight-amount: 0%;
--darken-amount: 10%;
}
}
button[value="cancel"] {
--button-background: color-mix(in oklab, var(--button-bg), var(--background) 50%);
}
input:not([type="hidden"]) ~ button {
border-radius: 0 var(--radius) var(--radius) 0;
border-left: none;
border-left: none;
}
input {
border: thin solid currentColor;
}
.field {
display: flex;
gap: var(--quarter-spacing);
}
.think-editor {
display: grid;
gap: var(--spacing);
@ -65,6 +127,12 @@ header {
/ var(--col-1-width) var(--editor-size) var(--preview-size)
;
& > * {
border: thin solid currentColor;
padding: var(--quarter-spacing);
}
&:has(#main-nav[open], #log[open]) {
--col-1-width: 20em;
}
@ -83,7 +151,7 @@ header {
}
& summary {
background: #eee;
background: color-mix(in oklab, var(--background), var(--color) 10%);
}
& > #main-nav > nav {
@ -120,6 +188,17 @@ header {
align-items: start;
}
& #jj-buttons {
display: flex;
justify-content: end;
gap: var(--spacing);
margin: 0;
& #start-commit-button {
width: 10em;
}
}
& #make-log {
& > pre {
max-width: 20em;
@ -129,16 +208,19 @@ header {
}
& #editor {
overflow: hidden;
overflow-x: hidden;
flex-grow: 1;
flex-basis: var(--editor-size);
max-height: 85vh;
grid-area: editor;
& #editor-controls {
position: sticky;
top: 0;
display: flex;
gap: var(--spacing);
justify-content: space-between;
border-bottom: thin solid currentColor;
& > details {
text-align: right;
@ -151,6 +233,8 @@ header {
margin: var(--half-spacing) 0;
}
}
background: var(--background);
z-index: 1;
}
& #code-editor {
@ -189,13 +273,43 @@ header {
#file-form {
overflow: auto;
max-height: 100%;
max-width: 100%;
}
dialog h2:first-child {
margin-top: 0;
}
#jj-status {
max-height: 10em;
width: 100%;
overflow: auto;
}
dialog textarea {
width: 100%;
height: 6em;
resize-x: none;
}
dialog p:last-child {
display: flex;
justify-content: end;
gap: var(--spacing);
}
@media (max-width: 100ch) {
html {
font-size: min(3vw, 16px);
}
body {
grid-template-columns: calc(100svw - var(--spacing));
}
#editor-size {
display: none;
}
.think-editor {
overflow: visible;
grid-template:
@ -205,6 +319,8 @@ header {
"preview"
;
padding-left: var(--double-spacing);
& > * ~ * {
border-top: medium solid #888;
margin-top: var(--spacing);
@ -219,12 +335,13 @@ header {
}
& #file-tree {
max-height: 7em;
max-height: 30svh;
}
}
& #editor {
overflow: auto;
max-height: revert;
& #code-editor {
max-width: none;
}

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,14 @@
--spacing: 1em;
--half-spacing: calc(0.5 * var(--spacing));
--double-spacing: calc(2 * var(--spacing));
--background: hsl(70,100%,95%);
--color: black;
}
@media (prefers-color-scheme: dark) {
--background: hsl(70,100%,8%);
--color: white;
}
* {
@ -9,6 +17,11 @@
}
body {
color-scheme: light dark;
background: var(--background);
color: var(--color);
font-family: sans-serif;
& > header {
padding: var(--spacing);
@ -97,6 +110,20 @@ body.index {
max-width: 80ch;
white-space: pre-wrap;
}
& .jj {
writing-mode: vertical-lr;
vertical-align: middle;
font-size: 0.5em;
color: white;
background: black;
padding: 0.2em;
border-radius: 0.5em;
&.has {
font-weight: bold;
}
}
}
}
}
@ -240,3 +267,4 @@ body.thing-editor {
}
}
}

View file

@ -25,15 +25,7 @@
<ul class="thinks-list">
{% for think in recent_thinks %}
<li class="think">
<a href="{% url 'think' think.slug %}">{{think.slug}}</a>
<time datetime="{{think.creation_time|date:"c"}}">{{think.creation_time}}</time>
{% with readme=think.get_readme %}
{% if readme %}
<pre class="readme">{{readme|safe}}</pre>
{% endif %}
{% endwith %}
</li>
{% include "thinks/think_list_item.html" %}
{% endfor %}
</ul>
</section>
@ -45,19 +37,11 @@
<ul class="thinks-list">
{% for category in categories %}
<li>
<details open>
<details>
<summary>{% if category.grouper %}{{category.grouper}}{% else %}Uncategorised{% endif %}</summary>
<ul>
{% for think in category.list %}
<li class="think">
<a href="{% url 'think' think.slug %}">{{think.slug}}</a>
<time datetime="{{think.creation_time|date:"c"}}">{{think.creation_time}}</time>
{% with readme=think.get_readme %}
{% if readme %}
<pre class="readme">{{readme|safe}}</pre>
{% endif %}
{% endwith %}
</li>
{% include "thinks/think_list_item.html" %}
{% endfor %}
</ul>
</details>

View file

@ -0,0 +1,10 @@
<li class="think">
<a href="{% url 'think' think.slug %}">{{think.slug}}</a>
<time datetime="{{think.creation_time|date:"c"}}">{{think.creation_time}}</time>
<small class="jj {% if think.has_jj %}has{% endif %}">备份{% if think.has_jj %}✔{% else %}✗{% endif %}</small>
{% with readme=think.get_readme %}
{% if readme %}
<pre class="readme">{{readme|safe}}</pre>
{% endif %}
{% endwith %}
</li>

View file

@ -13,6 +13,8 @@ urlpatterns = [
path('think/<slug:slug>/delete-file', DeleteFileView.as_view(), name='delete_file'),
path('think/<slug:slug>/run-command', RunCommandView.as_view(), name='run_command'),
path('think/<slug:slug>/log', LogView.as_view(), name='log'),
path('think/<slug:slug>/jj/status', JJStatusView.as_view(), name='jj_status'),
path('think/<slug:slug>/jj/commit', JJCommitView.as_view(), name='jj_commit'),
path('new', CreateThinkView.as_view(), name='new_think'),
path('new/<slug:slug>', RemixThinkView.as_view(), name='remix_think'),
]

View file

@ -223,3 +223,22 @@ class LogView(ThinkMixin, generic.DetailView):
think = self.get_object()
return HttpResponse(think.get_log(), content_type='text/plain; charset=utf-8')
class JJStatusView(ThinkMixin, generic.detail.DetailView):
def get(self, request, *args, **kwargs):
status = self.get_object().jj_controller.status()
return JsonResponse({'status': status})
class JJCommitView(ThinkMixin, generic.UpdateView):
form_class = forms.GitCommitForm
def form_valid(self, form):
message = form.cleaned_data['message']
think = form.instance
jj = think.jj_controller
res = jj.commit(message)
return JsonResponse({'ok': res.returncode == 0, 'think': think.pk, 'stdout': res.stdout, 'stderr': res.stderr})

View file

@ -131,3 +131,5 @@ THINKS_DIR.mkdir(parents=True, exist_ok=True)
THINKS_STATIC_URL = 'https://{slug}.think.somethingorotherwhatever.com'
API_TOKEN = ''
GIT_REPO_URL_TEMPLATE = ''