first commit

This commit is contained in:
Christian Lawson-Perfect 2024-03-04 15:05:35 +00:00
commit 52ab7aa331
33 changed files with 33447 additions and 0 deletions

0
thinks/__init__.py Normal file
View file

3
thinks/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
thinks/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ThinksConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'thinks'

126
thinks/forms.py Normal file
View file

@ -0,0 +1,126 @@
from django import forms
from django.conf import settings
from .models import Think
class CreateThinkForm(forms.ModelForm):
class Meta:
model = Think
fields = ['slug']
def save(self, commit=True):
print("SAVE", commit)
instance = super().save(commit)
if commit:
instance.root.mkdir(exist_ok=True,parents=True)
return instance
class RemixThinkForm(forms.ModelForm):
class Meta:
model = Think
fields = []
class RenameThinkForm(forms.ModelForm):
class Meta:
model = Think
fields = ['slug']
def __init__(self, *args, instance=None, **kwargs):
self.original_root = None if instance is None else instance.root
super().__init__(*args, instance=instance, **kwargs)
def clean_slug(self):
slug = self.cleaned_data['slug']
root = settings.THINKS_DIR / slug
if root.exists() and slug != self.instance.slug:
raise forms.ValidationError("A think with the slug {slug} already exists")
return slug
def save(self, commit=True):
slug = self.cleaned_data['slug']
root = settings.THINKS_DIR / slug
self.original_root.rename(root)
instance = super().save(commit)
return instance
class SaveFileForm(forms.ModelForm):
path = forms.CharField()
content = forms.CharField(required=False, widget=forms.Textarea)
class Meta:
model = Think
fields = []
def clean_path(self):
return self.instance.file_path(self.cleaned_data['path'])
def save(self, commit=False):
path = self.cleaned_data['path']
content = self.cleaned_data['content']
if not path.parent.exists():
path.parent.mkdir(exist_ok=True, parents=True)
with open(path, 'w') as f:
f.write(content)
return super().save(commit)
class RenameFileForm(forms.ModelForm):
path = forms.CharField()
newpath = forms.CharField()
class Meta:
model = Think
fields = []
def clean_path(self):
path = self.instance.file_path(self.cleaned_data['path'])
return path
def clean_newpath(self):
path = self.instance.file_path(self.cleaned_data['newpath'])
if path.exists():
raise forms.ValidationError("The file {path} already exists")
return path
def save(self, commit=False):
oldpath = self.cleaned_data['path']
newpath = self.cleaned_data['newpath']
print(oldpath,">",newpath)
oldpath.rename(newpath)
return super().save(commit)
class DeleteFileForm(forms.ModelForm):
path = forms.CharField()
class Meta:
model = Think
fields = []
def clean_path(self):
path = self.instance.file_path(self.cleaned_data['path'])
if path == self.instance.root:
raise forms.ValidationError("Can't delete the think's root")
return path
def save(self, commit=False):
path = self.cleaned_data['path']
path.unlink()
return super().save(commit)
class RunCommandForm(forms.ModelForm):
command = forms.CharField()
class Meta:
model = Think
fields = []

View file

View file

View file

@ -0,0 +1,135 @@
"""
Requires the ``watchdog`` package.
Watch the current directory and its children for changes to files, and run ``make`` when certain files are changed.
Can be configured by a .watchmakerc file, containing settings in YAML format:
path = <the path to watch for changes> (default: .)
default_make: <a list of `make` targets to run> (default: empty list, so the first target in the Makefile)
extensions: <a list of file extensions that should trigger a ``make`` run> (default: '.js')
"""
from copy import deepcopy
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from datetime import datetime
import os
import subprocess
import sys
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import yaml
from pathlib import Path
class MakeHandler(FileSystemEventHandler):
last_time = None
gap = 2
extensions = None
default_config = {
'path': '.',
'default_make': [],
'extensions': ['.js'],
}
def __init__(self, root):
super().__init__()
self.configs = {}
self.root = root.resolve()
def get_think_root(self, d):
while d.parent != self.root:
d = d.parent
return d
def get_config(self, p):
d = self.get_think_root(p)
if d not in self.configs:
self.load_config(d)
return self.configs[d]
def load_config(self, d):
print(f"Fetch config for {d}")
config_path = d / '.watchmakerc'
config = deepcopy(self.default_config)
if config_path.exists():
with open(config_path) as f:
config.update(yaml.load(f.read(),Loader=yaml.SafeLoader))
self.configs[d] = config
def on_modified(self, event):
root = self.root
if event.is_directory:
return
t = datetime.now()
src_path = Path(event.src_path).resolve()
if self.extensions is None or src_path.suffix in self.extensions:
if not src_path.is_relative_to(root.resolve()):
return
print('{} modified at {}'.format(root / src_path.relative_to(root.resolve()),t))
if src_path.name == '.watchmakerc' or (self.last_time is None or (t-self.last_time).seconds > self.gap):
self.run(src_path)
self.last_time = t
def run(self, p):
d = self.get_think_root(p)
print(f"Run for {d}")
print(p.name)
if p.name == '.watchmakerc':
self.load_config(d)
config = self.get_config(p)
print(config)
if (d / 'Makefile').exists():
print("Making")
command = ['make'] + config.get('default_make', [])
res = subprocess.run(command, cwd=d, capture_output=True, encoding='utf-8')
with open(d / '.make.log', 'w') as f:
f.write(res.stdout)
f.write(res.stderr)
else:
print("No make")
class Command(BaseCommand):
def handle(self, *args, **options):
self.rootpath = settings.THINKS_DIR
self.run()
def run(self,targets=None):
print(f"Watching {self.rootpath}")
event_handler = MakeHandler(self.rootpath)
observer = Observer()
observer.schedule(event_handler,str(self.rootpath),recursive=True)
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()

View file

@ -0,0 +1,21 @@
# Generated by Django 5.0.2 on 2024-03-04 14:43
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Think',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField()),
],
),
]

View file

45
thinks/models.py Normal file
View file

@ -0,0 +1,45 @@
from django.conf import settings
from django.db import models
from django.urls import reverse
THINKS_DIR = settings.THINKS_DIR
# Create your models here.
class Think(models.Model):
slug = models.SlugField()
class Meta:
ordering = ('slug',)
@property
def root(self):
return (THINKS_DIR / self.slug).resolve()
def get_absolute_url(self):
return reverse('think', kwargs={'slug': self.slug})
def get_static_url(self):
return settings.THINKS_STATIC_URL.format(slug=self.slug)
def files(self):
for f in self.root.iterdir():
yield f.relative_to(self.root)
def file_path(self, relpath):
if relpath is None:
return None
path = (self.root / relpath).resolve()
if not path.is_relative_to(self.root):
raise Exception(f"Bad path {path}")
return path
def get_readme(self):
readme_path = self.file_path('README')
for suffix in ['', '.md', '.rst', '.txt']:
p = readme_path.with_suffix(suffix)
if p.exists():
with open(p) as f:
return f.read()
return None

6
thinks/random_slug.py Normal file
View file

@ -0,0 +1,6 @@
from random import choice
words = 'avocado biscuit chocolate doughnut eclaire fudge goulash haddock icing juice koala lemon melon nut'.split(' ')
def random_slug():
return '-'.join(choice(words) for i in range(3))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,119 @@
:root {
--spacing: 1em;
}
* {
box-sizing: border-box;
}
body > header {
padding: var(--spacing);
& h1 {
margin: 0;
}
}
body.index {
& #thinks-list {
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--spacing);
& > .think {
& .readme {
max-width: 80ch;
white-space: pre-wrap;
}
}
}
}
body.thing-editor {
margin: 0;
width: 100vw;
height: 100vh;
display: grid;
grid-template:
"header" auto
"main" 1fr
;
gap: var(--spacing);
/*! overflow: hidden; */
& main {
display: grid;
gap: var(--spacing);
grid-template: "nav editor preview" / auto 3fr 2fr;
height: 100%;
& > nav {
display: flex;
flex-direction: column;
gap: var(--spacing);
& #file-tree {
margin: 0;
overflow: auto;
}
& form {
display: grid;
grid-template-columns: 1fr 6em;
align-content: start;
align-items: start;
}
}
& #editor {
& #editor-controls {
display: flex;
gap: var(--spacing);
justify-content: space-between;
}
& #code-editor {
display: block;
max-width: 50vw;
}
}
& #preview {
display: flex;
flex-direction: column;
& > iframe {
width: 100%;
height: 100%;
border: none;
}
}
}
}
#file-form {
overflow: auto;
/*! max-height: 100%; */
max-width: 100%;
}
@media (max-width: 100ch) {
html {
font-size: min(3vw, 16px);
}
body.thing-editor {
& main {
grid-template:
"nav" min-content "editor" 40vh "preview";
& nav {
flex-direction: row;
flex-wrap: wrap;
& form {
flex-grow: 1;
}
}
}
}
}

View file

@ -0,0 +1,28 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Thinks{% endblock %}</title>
{% block stylesheets %}
<link rel="stylesheet" href="{% static "thinks/thinks.css" %}">
{% endblock stylesheets %}
{% block scripts %}
<script type="module" src="{% static "thinks/code-editor.mjs" %}"></script>
{% endblock scripts %}
</head>
<body class="{% block body_class %}{% endblock %}">
{% block body %}
<header>
{% block header %}{% endblock %}
</header>
<main>
{% block main %}{% endblock %}
</main>
{% endblock body %}
</body>
</html>

View file

@ -0,0 +1,15 @@
{% extends "thinks/base.html" %}
{% block header %}
<h1>Delete {{think.slug}}</h1>
{% endblock header %}
{% block main %}
<form method="post" >
{{form}}
{% csrf_token %}
<button type="submit">Delete</button>
</form>
{% endblock main %}

View file

@ -0,0 +1,24 @@
{% extends "thinks/base.html" %}
{% block body_class %}index{% endblock %}
{% block header %}
<h1>Thinks</h1>
{% endblock header %}
{% block main %}
<ul id="thinks-list">
{% for think in object_list %}
<li class="think">
<a href="{% url 'think' think.slug %}">{{think.slug}}</a>
{% with readme=think.get_readme %}
{% if readme %}
<pre class="readme">{{readme}}
</pre>
{% endif %}
{% endwith %}
</li>
{% endfor %}
</ul>
<a href="{% url 'new_think' %}">New think</a>
{% endblock main %}

View file

@ -0,0 +1,15 @@
{% extends "thinks/base.html" %}
{% block header %}
<h1>New think</h1>
{% endblock header %}
{% block main %}
<form method="post" >
{{form}}
{% csrf_token %}
<button type="submit">Create</button>
</form>
{% endblock main %}

View file

@ -0,0 +1,15 @@
{% extends "thinks/base.html" %}
{% block header %}
<h1>Remix {{think.slug}}</h1>
{% endblock header %}
{% block main %}
<form method="post" >
{{form}}
{% csrf_token %}
<button type="submit">Remix</button>
</form>
{% endblock main %}

View file

@ -0,0 +1,15 @@
{% extends "thinks/base.html" %}
{% block header %}
<h1>{{think.slug}}</h1>
{% endblock header %}
{% block main %}
<form method="post" >
{{form}}
{% csrf_token %}
<button type="submit">Rename</button>
</form>
{% endblock main %}

View file

@ -0,0 +1,106 @@
{% extends "thinks/base.html" %}
{% block body_class %}thing-editor {{block.super}}{% endblock %}
{% block header %}
<a href="/">thinks</a>
<h1>{{think.slug}}</h1>
<a href="{% url 'rename_think' slug=think.slug %}">Rename</a>
<a href="{% url 'delete_think' slug=think.slug %}">Delete</a>
<a href="{% url 'remix_think' slug=think.slug %}">Remix</a>
{% endblock header %}
{% block main %}
<nav>
<a target="preview" href="{{think.get_static_url}}">Preview</a>
<ul id="file-tree">
{% for name, path in files %}
<li><a href="?path={{path}}">{{name}}</a></li>
{% endfor %}
</ul>
<form id="new-file-form" method="post" action="{% url 'save_file' slug=think.slug %}" enctype="multipart/form-data">
<input aria-labelledby="new-file-button" id="new_file_path" type="text" name="path">
<button id="new-file-button" type="submit">New file</button>
{% csrf_token %}
</form>
<form method="post" action="{% url 'run_command' slug=think.slug %}">
<input aria-labelledby="run-command-button" name="command">
{% csrf_token %}
<button id="run-command-button" type="submit">Run</button>
</form>
</nav>
<section id="editor">
{% if path is not None and not path.is_dir %}
<nav id="editor-controls">
{{path}}
<details>
<summary>actions</summary>
<form method="post" action="{% url 'delete_file' slug=think.slug %}">
{% csrf_token %}
<input type="hidden" name="path" value="{{path}}">
<button type="submit">Delete</button>
</form>
<form method="post" action="{% url 'rename_file' slug=think.slug %}">
{% csrf_token %}
<input type="hidden" name="path" value="{{path}}">
<input type="text" name="newpath" value="{{path}}">
<button type="submit">Rename</button>
</form>
</details>
</nav>
<form id="file-form" method="post" action="{% url 'save_file' slug=think.slug %}" enctype="multipart/form-data">
{{file_form.path.as_hidden}}
{{file_form.content.as_hidden}}
<code-editor id="code-editor">{{content}}</code-editor>
{% csrf_token %}
</form>
{% endif %}
</section>
<section id="preview">
<button type="button" id="reload-preview">Reload</button>
<iframe id="preview-frame" src="{{think.get_static_url}}"></iframe>
</section>
<script>
const file_form = document.getElementById('file-form');
const preview_frame = document.getElementById('preview-frame');
const content_input = document.getElementById('code-editor');
function debounce(fn) {
let last = null;
return function() {
if(last) {
clearTimeout(last);
}
last = setTimeout(fn, 2000);
}
}
function reload_preview() {
preview_frame.src = preview_frame.src;
}
if(content_input) {
const save_content = debounce(async() => {
const value = content_input.value;
console.log('save', value);
file_form.querySelector('[name="content"]').value = value;
await fetch(file_form.action, {method: 'POST', body: new FormData(file_form)});
reload_preview();
});
content_input.addEventListener('change', save_content);
}
console.log('hey');
document.getElementById('reload-preview').addEventListener('click', () => {
reload_preview();
})
</script>
{% endblock main %}

3
thinks/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

18
thinks/urls.py Normal file
View file

@ -0,0 +1,18 @@
from django.contrib import admin
from django.urls import path
from .views import *
urlpatterns = [
path('', IndexView.as_view(), name='index'),
path('think/<slug:slug>', ThinkView.as_view(), name='think'),
path('think/<slug:slug>/rename', RenameThinkView.as_view(), name='rename_think'),
path('think/<slug:slug>/delete', DeleteThinkView.as_view(), name='delete_think'),
path('think/<slug:slug>/file/<path:path>', ReadFileView.as_view(), name='read_file'),
path('think/<slug:slug>/save-file', SaveFileView.as_view(), name='save_file'),
path('think/<slug:slug>/rename-file', RenameFileView.as_view(), name='rename_file'),
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('new', CreateThinkView.as_view(), name='new_think'),
path('new/<slug:slug>', RemixThinkView.as_view(), name='remix_think'),
]

159
thinks/views.py Normal file
View file

@ -0,0 +1,159 @@
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render, redirect
from django.views import generic
from django.urls import reverse
from pathlib import Path
import shutil
import subprocess
from . import forms
from .models import Think
from .random_slug import random_slug
class ThinkMixin:
model = Think
context_object_name = 'think'
class IndexView(ThinkMixin, generic.ListView):
template_name = 'thinks/index.html'
class CreateThinkView(ThinkMixin, generic.CreateView):
template_name = 'thinks/new.html'
form_class = forms.CreateThinkForm
class RemixThinkView(ThinkMixin, generic.UpdateView):
template_name = 'thinks/remix.html'
form_class = forms.RemixThinkForm
def dispatch(self, request, *args, **kwargs):
if 'referer' in request.headers:
return super().dispatch(request, *args, **kwargs)
return self.do_remix()
def form_valid(self, form):
return self.do_remix()
def do_remix(self):
slug = self.kwargs['slug']
nslug = random_slug()
while (settings.THINKS_DIR / nslug).exists():
nslug = random_slug()
source = Think.objects.get(slug=slug)
think = Think.objects.create(slug=nslug)
shutil.copytree(source.root, think.root)
return redirect(think.get_absolute_url())
class ThinkView(ThinkMixin, generic.DetailView):
template_name = 'thinks/think.html'
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
think = self.object
strpath = self.request.GET.get('path')
path = think.file_path(strpath)
relpath = path.relative_to(think.root) if path is not None else None
if path is None:
directory = think.root
elif path.is_dir():
directory = path
else:
directory = path.parent
files = [(p.name, p.relative_to(think.root)) for p in directory.iterdir()]
if directory != think.root:
files.insert(0, ('..', directory.parent.relative_to(think.root)))
context['files'] = files
context['directory'] = directory
context['path'] = relpath
if path is not None and path.is_file():
with open(path) as f:
content = f.read()
else:
content = ''
context['content'] = content
context['file_form'] = forms.SaveFileForm(instance=think, initial={'content': content, 'path': relpath})
return context
class RenameThinkView(ThinkMixin, generic.UpdateView):
form_class = forms.RenameThinkForm
template_name = 'thinks/rename.html'
def get_success_url(self):
return self.object.get_absolute_url()
class DeleteThinkView(ThinkMixin, generic.DeleteView):
template_name = 'thinks/delete.html'
def get_success_url(self):
return reverse('index')
class ReadFileView(ThinkMixin, generic.DetailView):
def get(self, request, *args, **kwargs):
think = self.get_object()
relpath = self.kwargs['path']
path = think.root / relpath
print(path)
class SaveFileView(ThinkMixin, generic.UpdateView):
form_class = forms.SaveFileForm
template_name = 'thinks/save_file.html'
def form_valid(self, form):
self.path = form.cleaned_data['path'].relative_to(self.object.root)
return super().form_valid(form)
def get_success_url(self):
return self.object.get_absolute_url()+'?path='+str(self.path)
class RenameFileView(ThinkMixin, generic.UpdateView):
form_class = forms.RenameFileForm
template_name = 'thinks/rename_file.html'
def form_valid(self, form):
self.path = form.cleaned_data['newpath'].relative_to(self.object.root)
print(form)
return super().form_valid(form)
def get_success_url(self):
return self.object.get_absolute_url()+'?path='+str(self.path)
class DeleteFileView(ThinkMixin, generic.UpdateView):
form_class = forms.DeleteFileForm
template_name = 'thinks/delete_file.html'
def form_valid(self, form):
self.path = form.cleaned_data['path'].relative_to(self.object.root)
return super().form_valid(form)
def get_success_url(self):
return self.object.get_absolute_url()+'?path='+str(self.path.parent)
class RunCommandView(ThinkMixin, generic.UpdateView):
form_class = forms.RunCommandForm
def form_valid(self, form):
think = self.object
command = form.cleaned_data['command']
res = subprocess.run(
command.split(' '),
cwd=think.root,
encoding='utf8',
capture_output=True
)
return JsonResponse({'stdout': res.stdout, 'stderr': res.stderr})