Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7e79ba0
Fix UI polish: antialiasing, fonts, home button, Tab cycling, graph n…
claude Sep 16, 2026
d5c68dc
Fix data persistence, dashboard theming, and dishonest cloud export UI
claude Sep 16, 2026
78ee8c0
Fix Find & Replace, theme responsiveness, and dashboard stats
claude Sep 16, 2026
a8c50dc
Remove stale theme.current() from cloud export dialog
claude Sep 16, 2026
3214a56
Convert construction-time theme.current() to objectName CSS styling
claude Sep 16, 2026
46d6170
Fix theme toggle for highlighter, completer, and wordmark
claude Sep 16, 2026
37b33db
Fix collaboration bugs: OT transform, relay crash, LAN port, session …
claude Sep 16, 2026
5fb81f3
Flip title bar layout and replace Unicode icons with Heroicons
claude Sep 16, 2026
cbd4ae1
Add comprehensive accessibility support and text size controls
claude Sep 16, 2026
5589ae4
Fix deprecated Qt.UserRole and remove unused imports
claude Sep 16, 2026
f36defc
Fix CRDT data loss, Replace All escape bug, and regex match index
claude Sep 17, 2026
554097f
Fix 7 collab bugs: LAN join, OT transform, thread safety, undo history
claude Sep 17, 2026
a946dc2
Fix 5 bugs from adversarial review: host race, title template, replac…
claude Sep 17, 2026
a33338f
Fix session thread safety, element cycle, collab undo, and dead code
claude Sep 17, 2026
55144b6
Fix OT tie-breaking, proofread navigation, signal duplication, auto-sync
claude Sep 17, 2026
9938272
Replace error swallowing with proper logging, honest cloud stubs, exp…
claude Sep 17, 2026
f146276
Fix review findings: collab handler regression, exception cleanup, co…
claude Sep 17, 2026
e6ecd4b
Fix 5 bugs from full PR code review
claude Sep 17, 2026
a210d8e
Add comic book Fountain extensions and outline-manuscript sync
claude Sep 17, 2026
ef481ee
Fix 17 bugs from adversarial code review of comic extensions
claude Sep 17, 2026
2c35722
Fix CI: skip comic fountain tests when ecrit_core unavailable
claude Sep 17, 2026
8ab2e56
Hide plugin marketplace from UI until backend is built
claude Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
- **Series Manager** — Multi-episode/season management with a shared series bible (characters, locations, props, themes, backstory)
- **Git Snapshots** — Local version control with create, list, and restore
- **Remote Sync** — Push/pull to GitHub, GitLab, or Codeberg
- **Cloud Export** — Export to Google Drive, iCloud, Dropbox, OneDrive, or Nextcloud
- **Cloud Export** — Export scaffolding for Google Drive, iCloud, Dropbox, OneDrive, and Nextcloud (provider SDKs not yet integrated)
- **Share for Review** — Generate self-contained, watermarked HTML files for confidential review
- **Real-time Collaboration** — LAN-based peer-to-peer editing with CRDT conflict resolution and presence indicators
- **Plugin Marketplace** — Browse, install, and manage community plugins with a built-in marketplace UI
Expand Down Expand Up @@ -153,6 +153,7 @@ Use **Import Script** from the Dashboard or Command Palette to open `.fountain`

- **Python 3.12+** (recommended; 3.10+ supported)
- **Rust 1.70+** (for the native core)
- **maturin** (`pip install maturin`)
- **Qt 6** (installed via PySide6)

### Setup
Expand All @@ -169,9 +170,9 @@ source .venv/bin/activate # or .venv\Scripts\activate on Windows
# Install Python dependencies
pip install -e ".[dev]"

# Build the Rust core
# Build the Rust core (PyO3 extension)
cd src-tauri
cargo build --release
maturin develop --release
cd ..

# Run the app
Expand Down Expand Up @@ -224,7 +225,7 @@ Ecrit/
│ ├── fountain/ # Fountain parser
│ ├── project/ # Project file management
│ └── lib.rs # PyO3 module
├── tests/ # Test suite (766 tests)
├── tests/ # Test suite (879 tests)
├── openspec/ # Feature tracking and roadmap
└── public/ # Static assets
```
Expand Down
57 changes: 54 additions & 3 deletions ecrit/collab/crdt.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,40 @@ def delete(self, position: int, length: int) -> Operation:
def transform(self, op1: Operation, op2: Operation) -> Operation:
"""Operational transform: adjust op1 against already-applied op2."""
if op2.op_type == OperationType.INSERT:
if op1.position >= op2.position:
insert_len = len(op2.text)
if op1.op_type == OperationType.DELETE and op1.position < op2.position < op1.position + op1.length:
# Insert landed inside delete range — expand delete to cover the
# gap the insert created so all originally-targeted chars are removed
return Operation(
op_type=op1.op_type,
position=op1.position + len(op2.text),
position=op1.position,
text=op1.text,
length=op1.length + insert_len,
user_id=op1.user_id,
timestamp=op1.timestamp,
revision=op1.revision,
)
if op1.position > op2.position or (
op1.position == op2.position
and (
op1.op_type == OperationType.DELETE
or (op1.op_type == OperationType.INSERT
and (op1.user_id or "") >= (op2.user_id or ""))
)
):
return Operation(
op_type=op1.op_type,
position=op1.position + insert_len,
text=op1.text,
length=op1.length,
user_id=op1.user_id,
timestamp=op1.timestamp,
revision=op1.revision,
)
elif op2.op_type == OperationType.DELETE:
if op1.position >= op2.position + op2.length:
op2_end = op2.position + op2.length
if op1.position >= op2_end:
# op1 is entirely after op2's deleted range
return Operation(
op_type=op1.op_type,
position=op1.position - op2.length,
Expand All @@ -116,7 +138,36 @@ def transform(self, op1: Operation, op2: Operation) -> Operation:
timestamp=op1.timestamp,
revision=op1.revision,
)
if op1.op_type == OperationType.DELETE:
op1_end = op1.position + op1.length
# Compute overlap between the two delete ranges
overlap_start = max(op1.position, op2.position)
overlap_end = min(op1_end, op2_end)
overlap = max(0, overlap_end - overlap_start)
new_length = op1.length - overlap
if new_length <= 0:
# op1's delete is entirely subsumed by op2 — becomes a no-op
return Operation(
op_type=op1.op_type,
position=op2.position if op1.position >= op2.position else op1.position,
text=op1.text,
length=0,
user_id=op1.user_id,
timestamp=op1.timestamp,
revision=op1.revision,
)
new_pos = min(op1.position, op2.position)
return Operation(
op_type=op1.op_type,
position=new_pos,
text=op1.text,
length=new_length,
user_id=op1.user_id,
timestamp=op1.timestamp,
revision=op1.revision,
)
elif op1.position >= op2.position:
# INSERT inside op2's deleted range — move to op2.position
return Operation(
op_type=op1.op_type,
position=op2.position,
Expand Down
6 changes: 6 additions & 0 deletions ecrit/collab/lan.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class LANPeer:
ip_address: str
port: int
project_title: str = ""
session_id: str = ""
last_seen: float = 0.0

def is_stale(self, timeout: float = 10.0) -> bool:
Expand Down Expand Up @@ -76,13 +77,17 @@ def get_peers(self) -> list[LANPeer]:
self._on_peer_lost(uid)
return list(self._peers.values())

def set_session_id(self, session_id: str) -> None:
self._session_id = session_id

def _make_announce(self) -> bytes:
payload = json.dumps({
"service": SERVICE_ID,
"user_id": self.user_id,
"user_name": self.user_name,
"port": self.port,
"project_title": self._project_title,
"session_id": getattr(self, "_session_id", ""),
})
return payload.encode("utf-8")

Expand Down Expand Up @@ -125,6 +130,7 @@ def _listen_loop(self) -> None:
ip_address=addr[0],
port=payload.get("port", 8741),
project_title=payload.get("project_title", ""),
session_id=payload.get("session_id", ""),
last_seen=time.time(),
)
with self._peers_lock:
Expand Down
35 changes: 25 additions & 10 deletions ecrit/collab/p2p.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def __init__(self, user_id: str, user_name: str):
self.user_name = user_name
self._server_socket: Optional[socket.socket] = None
self._clients: dict[str, socket.socket] = {}
self._clients_lock = threading.Lock()
self._running = False
self._listen_thread: Optional[threading.Thread] = None
self._on_message: Optional[Callable[[str, str], None]] = None
Expand Down Expand Up @@ -85,7 +86,7 @@ def host_session(self, port: int = 0, project_title: str = "") -> ConnectionToke
self._server_socket.listen(8)
self._server_socket.settimeout(1.0)
self._port = self._server_socket.getsockname()[1]
self._session_id = base64.urlsafe_b64encode(os.urandom(6)).decode()
self._session_id = base64.urlsafe_b64encode(os.urandom(16)).decode().rstrip("=")
self._running = True

self._listen_thread = threading.Thread(target=self._accept_loop, daemon=True)
Expand Down Expand Up @@ -113,7 +114,8 @@ def join_session(self, token: ConnectionToken) -> bool:
"session_id": token.session_id,
})
self._send_frame(sock, hello)
self._clients["host"] = sock
with self._clients_lock:
self._clients["host"] = sock
self._running = True

recv_thread = threading.Thread(
Expand All @@ -125,7 +127,8 @@ def join_session(self, token: ConnectionToken) -> bool:
return False

def send(self, peer_id: str, message: str) -> bool:
sock = self._clients.get(peer_id)
with self._clients_lock:
sock = self._clients.get(peer_id)
if sock:
try:
self._send_frame(sock, message)
Expand All @@ -136,8 +139,10 @@ def send(self, peer_id: str, message: str) -> bool:
return False

def broadcast(self, message: str) -> None:
with self._clients_lock:
snapshot = list(self._clients.items())
dead = []
for peer_id, sock in self._clients.items():
for peer_id, sock in snapshot:
try:
self._send_frame(sock, message)
except OSError:
Expand All @@ -147,12 +152,14 @@ def broadcast(self, message: str) -> None:

def close(self) -> None:
self._running = False
for sock in self._clients.values():
with self._clients_lock:
socks = list(self._clients.values())
self._clients.clear()
for sock in socks:
try:
sock.close()
except OSError:
pass
self._clients.clear()
if self._server_socket:
try:
self._server_socket.close()
Expand All @@ -161,10 +168,16 @@ def close(self) -> None:
self._server_socket = None

def is_connected(self) -> bool:
return self._running and len(self._clients) > 0
with self._clients_lock:
return self._running and len(self._clients) > 0

def get_peer_count(self) -> int:
return len(self._clients)
with self._clients_lock:
return len(self._clients)

def get_client_ids(self) -> list[str]:
with self._clients_lock:
return list(self._clients.keys())

def _send_frame(self, sock: socket.socket, data: str) -> None:
encoded = data.encode("utf-8")
Expand Down Expand Up @@ -201,7 +214,8 @@ def _accept_loop(self) -> None:
if hello.get("session_id") != self._session_id:
client_sock.close()
continue
self._clients[peer_id] = client_sock
with self._clients_lock:
self._clients[peer_id] = client_sock
if self._on_connect:
self._on_connect(peer_id)
recv_thread = threading.Thread(
Expand Down Expand Up @@ -229,7 +243,8 @@ def _recv_loop(self, peer_id: str, sock: socket.socket) -> None:
self._remove_peer(peer_id)

def _remove_peer(self, peer_id: str) -> None:
sock = self._clients.pop(peer_id, None)
with self._clients_lock:
sock = self._clients.pop(peer_id, None)
if sock:
try:
sock.close()
Expand Down
Loading
Loading