diff --git a/.gitignore b/.gitignore
index f5f4011..e7ace45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.env
+.env.local
.playwright-mcp/
CLAUDE.md
SECURITYPLAN.md
diff --git a/README.md b/README.md
index 9bba0c0..c9d0cf3 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ All runtime configuration lives in `.env`. Edit it before building:
| `NODE_VERSION_YCMD` | `16.20.2` | Node.js version in ycmd image |
| `NGINX_PORT` | `8080` | Host port nginx binds to |
| `PUBLIC_URL` | `http://localhost:8080` | Public-facing URL (see note below) |
+| `GITHUB_HOOK_URL` | *(uses PUBLIC_URL)* | Base URL for GitHub webhook callbacks (see ngrok note below) |
| `EXPECT_SSL` | `no` | Set to `yes` for HTTPS deployments |
| `EMULATOR_FIXED_LIMIT` | `90` | Max concurrent emulators |
| `LIBPEBBLE_PROXY` | `wss://cloudpebble-proxy.repebble.com/tool` | libpebble proxy WebSocket URL |
@@ -37,6 +38,29 @@ All runtime configuration lives in `.env`. Edit it before building:
> **Note:** `PUBLIC_URL` tells Django how the outside world reaches the site (used for generating callback URLs, media paths, etc.). `NGINX_PORT` controls which host port nginx binds to. In dev, they typically match; in production behind a reverse proxy, `PUBLIC_URL` is the external URL and `NGINX_PORT` may differ.
+### Using ngrok for GitHub webhooks in local development
+
+GitHub requires webhook callback URLs to be publicly reachable — it rejects `localhost` URLs. During local development, use [ngrok](https://ngrok.com) to expose your instance:
+
+```bash
+ngrok http 8080
+```
+
+Copy the HTTPS URL ngrok gives you (e.g. `https://abc123.ngrok-free.dev`). **Do not** set `PUBLIC_URL` to the ngrok URL — Firebase Auth requires `localhost` for sign-in. Instead, set `GITHUB_HOOK_URL` to the ngrok base URL so only webhook callbacks use it:
+
+```bash
+# In .env.local (or .env):
+GITHUB_HOOK_URL=https://abc123.ngrok-free.dev
+```
+
+Then rebuild and restart:
+
+```bash
+docker compose build && docker compose up -d
+```
+
+Now you can browse CloudPebble at `http://localhost:8080` (Firebase login works) while GitHub can POST webhook callbacks to the ngrok URL.
+
### Test GitHub Repo Sync locally
The `GitHub Repo Sync` card changes based on `PUBLIC_URL`, not the browser hostname.
diff --git a/cloudpebble/.gitignore b/cloudpebble/.gitignore
index 9c17dea..11706eb 100644
--- a/cloudpebble/.gitignore
+++ b/cloudpebble/.gitignore
@@ -12,3 +12,4 @@ src/
.env/
.idea/
bower_components
+node_modules/
diff --git a/cloudpebble/cloudpebble/settings.py b/cloudpebble/cloudpebble/settings.py
index 946fd6d..060ed11 100644
--- a/cloudpebble/cloudpebble/settings.py
+++ b/cloudpebble/cloudpebble/settings.py
@@ -417,7 +417,8 @@ def _redis_db_url(redis_url, db_index):
GITHUB_SYNC_CLIENT_ID = _environ.get('GITHUB_SYNC_CLIENT_ID', '')
GITHUB_SYNC_CLIENT_SECRET = _environ.get('GITHUB_SYNC_CLIENT_SECRET', '')
-GITHUB_HOOK_TEMPLATE = _environ.get('GITHUB_HOOK', PUBLIC_URL.rstrip('/') + '/ide/project/%(project)d/github/push_hook?key=%(key)s')
+GITHUB_HOOK_BASE_URL = _environ.get('GITHUB_HOOK_URL', '')
+GITHUB_HOOK_TEMPLATE = (GITHUB_HOOK_BASE_URL.rstrip('/') if GITHUB_HOOK_BASE_URL else PUBLIC_URL.rstrip('/')) + '/ide/project/%(project)d/github/push_hook?key=%(key)s'
SDK2_PEBBLE_WAF = _environ.get('SDK2_PEBBLE_WAF', '/sdk2/pebble/waf')
SDK3_PEBBLE_WAF = _environ.get('SDK3_PEBBLE_WAF', '/sdk3/pebble/waf')
diff --git a/cloudpebble/ide/api/git.py b/cloudpebble/ide/api/git.py
index 4707b4e..e5becbb 100644
--- a/cloudpebble/ide/api/git.py
+++ b/cloudpebble/ide/api/git.py
@@ -15,6 +15,19 @@
__author__ = 'katharine'
+def _parse_bool(value, default=False):
+ if value in (True, False):
+ return value
+ if isinstance(value, int):
+ return bool(value)
+ if isinstance(value, str):
+ if value in ('1', 'true', 'True'):
+ return True
+ if value in ('0', 'false', 'False', ''):
+ return False
+ return default
+
+
@login_required
@require_POST
@json_view
@@ -30,7 +43,8 @@ def github_push(request, project_id):
@json_view
def github_pull(request, project_id):
project = get_object_or_404(Project, pk=project_id, owner=request.user)
- task = do_github_pull.delay(project.id)
+ force = _parse_bool(request.POST.get('force', '0'))
+ task = do_github_pull.delay(project.id, force=force)
return {'task_id': task.task_id}
@@ -41,8 +55,9 @@ def set_project_repo(request, project_id):
project = get_object_or_404(Project, pk=project_id, owner=request.user)
repo = request.POST['repo']
branch = request.POST['branch']
- auto_pull = bool(int(request.POST['auto_pull']))
- auto_build = bool(int(request.POST['auto_build']))
+ auto_pull = _parse_bool(request.POST['auto_pull'])
+ auto_build = _parse_bool(request.POST['auto_build'])
+ hook_force = _parse_bool(request.POST.get('hook_force', '0'))
repo = ide.git.url_to_repo(repo)
if repo is None:
@@ -109,6 +124,7 @@ def set_project_repo(request, project_id):
project.github_hook_uuid = None
project.github_hook_build = auto_build
+ project.github_hook_force = hook_force
project.save()
diff --git a/cloudpebble/ide/api/project.py b/cloudpebble/ide/api/project.py
index 2621ee7..feda84d 100644
--- a/cloudpebble/ide/api/project.py
+++ b/cloudpebble/ide/api/project.py
@@ -454,8 +454,9 @@ def project_info(request, project_id):
'branch': project.github_branch if project.github_branch is not None else None,
'last_sync': str(project.github_last_sync) if project.github_last_sync is not None else None,
'last_commit': project.github_last_commit,
- 'auto_build': project.github_hook_build,
- 'auto_pull': project.github_hook_uuid is not None
+ 'auto_build': project.github_hook_build,
+ 'auto_pull': project.github_hook_uuid is not None,
+ 'hook_force': project.github_hook_force
},
'supported_platforms': project.supported_platforms,
'has_embeddedjs': project.has_embeddedjs_files,
diff --git a/cloudpebble/ide/api/sse.py b/cloudpebble/ide/api/sse.py
new file mode 100644
index 0000000..5ceb9de
--- /dev/null
+++ b/cloudpebble/ide/api/sse.py
@@ -0,0 +1,54 @@
+import json
+import logging
+
+from django.contrib.auth.decorators import login_required
+from django.http import StreamingHttpResponse
+from django.views.decorators.http import require_GET
+
+from utils.redis_helper import redis_client
+
+logger = logging.getLogger(__name__)
+
+
+class SSEEventStream:
+ def __init__(self, project_id):
+ self.channel = 'project_events:{}'.format(project_id)
+ self.pubsub = redis_client.pubsub()
+ self.pubsub.subscribe(self.channel)
+
+ def __iter__(self):
+ try:
+ for message in self.pubsub.listen():
+ if message['type'] == 'message':
+ data = message['data']
+ if isinstance(data, bytes):
+ data = data.decode('utf-8')
+ parsed = json.loads(data)
+ event_type = parsed.pop('type', 'message')
+ yield 'event: {}\ndata: {}\n\n'.format(event_type, json.dumps(parsed))
+ except GeneratorExit:
+ pass
+ finally:
+ try:
+ self.pubsub.unsubscribe(self.channel)
+ self.pubsub.close()
+ except Exception:
+ pass
+
+
+@login_required
+@require_GET
+def project_events(request, project_id):
+ from ide.models.project import Project
+ project = Project.objects.filter(pk=project_id, owner=request.user)
+ if not project.exists():
+ from django.http import HttpResponseNotFound
+ return HttpResponseNotFound()
+
+ response = StreamingHttpResponse(
+ SSEEventStream(project_id),
+ content_type='text/event-stream',
+ )
+ response['Cache-Control'] = 'no-cache'
+ response['X-Accel-Buffering'] = 'no'
+ return response
\ No newline at end of file
diff --git a/cloudpebble/ide/migrations/0013_github_hook_force.py b/cloudpebble/ide/migrations/0013_github_hook_force.py
new file mode 100644
index 0000000..04ef2e0
--- /dev/null
+++ b/cloudpebble/ide/migrations/0013_github_hook_force.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ide', '0012_env_vars'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='project',
+ name='github_hook_force',
+ field=models.BooleanField(default=False),
+ ),
+ ]
\ No newline at end of file
diff --git a/cloudpebble/ide/models/project.py b/cloudpebble/ide/models/project.py
index 04f8afd..354f197 100644
--- a/cloudpebble/ide/models/project.py
+++ b/cloudpebble/ide/models/project.py
@@ -67,6 +67,7 @@ class Project(IdeModel):
github_last_commit = models.CharField(max_length=40, blank=True, null=True)
github_hook_uuid = models.CharField(max_length=36, blank=True, null=True)
github_hook_build = models.BooleanField(default=False)
+ github_hook_force = models.BooleanField(default=False)
project_dependencies = models.ManyToManyField("Project", db_table='cloudpebble_project_dependencies')
diff --git a/cloudpebble/ide/static/ide/css/ide.css b/cloudpebble/ide/static/ide/css/ide.css
index 9f35bc8..1aeac74 100644
--- a/cloudpebble/ide/static/ide/css/ide.css
+++ b/cloudpebble/ide/static/ide/css/ide.css
@@ -1616,4 +1616,14 @@ label .text-icon {
}
.spinner-light {
background-image: url("/static/ide/img/spinner.gif");
+}
+
+.sidebar-pending {
+ display: inline-block;
+ font-size: 11px;
+ color: #ff4700;
+ margin-left: 6px;
+ font-family: sans-serif;
+ font-weight: bold;
+ vertical-align: middle;
}
\ No newline at end of file
diff --git a/cloudpebble/ide/static/ide/js/__tests__/sidebar-refresh.test.js b/cloudpebble/ide/static/ide/js/__tests__/sidebar-refresh.test.js
new file mode 100644
index 0000000..94d6b61
--- /dev/null
+++ b/cloudpebble/ide/static/ide/js/__tests__/sidebar-refresh.test.js
@@ -0,0 +1,217 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+
+function makeJqueryMock() {
+ var elements = {};
+ var $ = function(selector) {
+ if (typeof selector === 'function') {
+ selector();
+ return $;
+ }
+ var el = elements[selector] || {
+ empty: vi.fn(() => el),
+ append: vi.fn(() => el),
+ remove: vi.fn(() => el),
+ data: vi.fn(() => undefined),
+ find: vi.fn(() => $),
+ children: vi.fn(() => []),
+ addClass: vi.fn(() => el),
+ removeClass: vi.fn(() => el),
+ detach: vi.fn(() => el),
+ text: vi.fn((v) => v !== undefined ? (el._text = v, el) : (el._text || '')),
+ attr: vi.fn(() => el),
+ click: vi.fn(() => el),
+ siblings: vi.fn(() => $),
+ slideToggle: vi.fn(),
+ closest: vi.fn(() => $),
+ length: 0
+ };
+ elements[selector] = el;
+ return el;
+ };
+ $.Deferred = vi.fn(() => {
+ var callbacks = { done: [], fail: [], always: [] };
+ var promise = {
+ done: vi.fn((cb) => { callbacks.done.push(cb); return promise; }),
+ fail: vi.fn((cb) => { callbacks.fail.push(cb); return promise; }),
+ always: vi.fn((cb) => { callbacks.always.push(cb); return promise; }),
+ then: vi.fn((successCb, failCb) => {
+ if (successCb) callbacks.done.push(successCb);
+ if (failCb) callbacks.fail.push(failCb);
+ return promise;
+ }),
+ promise: vi.fn(() => promise)
+ };
+ var deferred = {
+ resolve: vi.fn((...args) => {
+ callbacks.done.forEach(cb => cb(...args));
+ callbacks.always.forEach(cb => cb());
+ }),
+ reject: vi.fn((...args) => {
+ callbacks.fail.forEach(cb => cb(...args));
+ callbacks.always.forEach(cb => cb());
+ }),
+ promise: vi.fn(() => promise),
+ then: promise.then,
+ done: promise.done,
+ fail: promise.fail,
+ always: promise.always
+ };
+ return deferred;
+ });
+ $.each = vi.fn((arr, fn) => {
+ if (Array.isArray(arr)) arr.forEach((v, i) => fn(i, v));
+ else if (typeof arr === 'object') Object.keys(arr).forEach(k => fn(k, arr[k]));
+ });
+ $._elements = elements;
+ return $;
+}
+
+function makeCloudPebble() {
+ return {
+ Editor: {
+ GetUnsavedFiles: vi.fn(() => 3),
+ Add: vi.fn(),
+ ReloadActive: vi.fn(() => Promise.resolve())
+ },
+ Resources: {
+ Add: vi.fn(),
+ AddAlloyAsset: vi.fn()
+ },
+ ProjectInfo: { type: 'native' },
+ TargetNames: { app: 'App', pkjs: 'PKJS' }
+ };
+}
+
+function loadSidebarModule(CloudPebble, $, Ajax) {
+ global.PROJECT_ID = 1;
+ global.CloudPebble = CloudPebble;
+ global.$ = $;
+ global.jQuery = $;
+ global.Ajax = Ajax;
+ global._ = { each: vi.fn((arr, fn) => arr.forEach(fn)) };
+
+ var sidebarPath = resolve(__dirname, '..', 'sidebar.js');
+ var code = readFileSync(sidebarPath, 'utf8');
+ var fn = new Function(code);
+ fn();
+
+ return CloudPebble.Sidebar;
+}
+
+describe('Sidebar.Refresh', () => {
+ let CloudPebble;
+ let Sidebar;
+ let originalGetUnsavedFiles;
+ let $;
+ let deferred;
+
+ beforeEach(() => {
+ $ = makeJqueryMock();
+ CloudPebble = makeCloudPebble();
+ originalGetUnsavedFiles = CloudPebble.Editor.GetUnsavedFiles;
+ deferred = $.Deferred();
+ var Ajax = {
+ Get: vi.fn(() => deferred.promise())
+ };
+ Sidebar = loadSidebarModule(CloudPebble, $, Ajax);
+ });
+
+ it('temporarily overrides GetUnsavedFiles to return 0 during refresh', () => {
+ Sidebar.Refresh();
+ expect(CloudPebble.Editor.GetUnsavedFiles()).toBe(0);
+ });
+
+ it('restores GetUnsavedFiles after successful ajax response', () => {
+ Sidebar.Refresh();
+ expect(CloudPebble.Editor.GetUnsavedFiles()).toBe(0);
+
+ deferred.resolve({
+ type: 'native',
+ source_files: [],
+ resources: []
+ });
+
+ expect(CloudPebble.Editor.GetUnsavedFiles).toBe(originalGetUnsavedFiles);
+ });
+
+ it('restores GetUnsavedFiles even when ajax request fails', () => {
+ Sidebar.Refresh();
+ expect(CloudPebble.Editor.GetUnsavedFiles()).toBe(0);
+
+ deferred.reject();
+
+ expect(CloudPebble.Editor.GetUnsavedFiles).toBe(originalGetUnsavedFiles);
+ });
+
+ it('restores GetUnsavedFiles when done callback throws', () => {
+ CloudPebble.Editor.Add = vi.fn(() => { throw new Error('boom'); });
+
+ Sidebar.Refresh();
+ try {
+ deferred.resolve({
+ type: 'native',
+ source_files: [{ target: 'app', name: 'main.c' }],
+ resources: []
+ });
+ } catch (e) {}
+
+ expect(CloudPebble.Editor.GetUnsavedFiles).toBe(originalGetUnsavedFiles);
+ });
+
+ it('updates ProjectInfo from ajax response', () => {
+ var newData = { type: 'pebblejs', source_files: [], resources: [] };
+ Sidebar.Refresh();
+ deferred.resolve(newData);
+ expect(CloudPebble.ProjectInfo).toBe(newData);
+ });
+
+ it('adds source files from response', () => {
+ Sidebar.Refresh();
+ deferred.resolve({
+ type: 'native',
+ source_files: [{ target: 'app', name: 'main.c' }],
+ resources: []
+ });
+ expect(CloudPebble.Editor.Add).toHaveBeenCalled();
+ });
+
+ it('adds resources from response', () => {
+ Sidebar.Refresh();
+ deferred.resolve({
+ type: 'native',
+ source_files: [],
+ resources: [{ id: 1, file_name: 'icon.png' }]
+ });
+ expect(CloudPebble.Resources.Add).toHaveBeenCalledWith({ id: 1, file_name: 'icon.png' });
+ });
+
+ it('calls Editor.ReloadActive after a successful refresh', () => {
+ Sidebar.Refresh();
+ deferred.resolve({
+ type: 'native',
+ source_files: [],
+ resources: []
+ });
+ expect(CloudPebble.Editor.ReloadActive).toHaveBeenCalled();
+ });
+
+ it('does not call Editor.ReloadActive when the ajax request fails', () => {
+ Sidebar.Refresh();
+ deferred.reject();
+ expect(CloudPebble.Editor.ReloadActive).not.toHaveBeenCalled();
+ });
+
+ it('does not throw when Editor.ReloadActive is not provided', () => {
+ delete CloudPebble.Editor.ReloadActive;
+ Sidebar.Refresh();
+ expect(() => {
+ deferred.resolve({
+ type: 'native',
+ source_files: [],
+ resources: []
+ });
+ }).not.toThrow();
+ });
+});
\ No newline at end of file
diff --git a/cloudpebble/ide/static/ide/js/__tests__/sse-events.test.js b/cloudpebble/ide/static/ide/js/__tests__/sse-events.test.js
new file mode 100644
index 0000000..c29f5bc
--- /dev/null
+++ b/cloudpebble/ide/static/ide/js/__tests__/sse-events.test.js
@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+
+function makeCloudPebble() {
+ return {
+ Sidebar: {
+ ShowPending: vi.fn(),
+ HidePending: vi.fn()
+ },
+ GitHub: {
+ OnPullStart: vi.fn(),
+ OnPullComplete: vi.fn(),
+ OnPullFailed: vi.fn()
+ },
+ Compile: {
+ OnBuildStart: vi.fn(),
+ OnBuildComplete: vi.fn()
+ }
+ };
+}
+
+function loadSseModule(CloudPebble) {
+ global.PROJECT_ID = 1;
+ global.CloudPebble = CloudPebble;
+ global.EventSource = vi.fn().mockImplementation(() => ({
+ addEventListener: vi.fn(),
+ close: vi.fn(),
+ readyState: 0
+ }));
+
+ const ssePath = resolve(__dirname, '..', 'sse.js');
+ const code = readFileSync(ssePath, 'utf8');
+ const fn = new Function(code);
+ fn();
+
+ return CloudPebble.Events;
+}
+
+describe('sse event handlers', () => {
+ let CloudPebble;
+ let events;
+
+ beforeEach(() => {
+ CloudPebble = makeCloudPebble();
+ events = loadSseModule(CloudPebble);
+ vi.clearAllMocks();
+ });
+
+ describe('handlePullStart', () => {
+ it('shows pending pill with Pulling text and calls OnPullStart', () => {
+ events.handlePullStart();
+ expect(CloudPebble.Sidebar.ShowPending).toHaveBeenCalledWith('github', 'Pulling');
+ expect(CloudPebble.GitHub.OnPullStart).toHaveBeenCalled();
+ });
+ });
+
+ describe('handlePullComplete', () => {
+ it('hides pending pill and calls OnPullComplete with parsed data', () => {
+ const event = { data: JSON.stringify({ github_last_commit: 'abc123' }) };
+ events.handlePullComplete(event);
+ expect(CloudPebble.Sidebar.HidePending).toHaveBeenCalledWith('github');
+ expect(CloudPebble.GitHub.OnPullComplete).toHaveBeenCalledWith({ github_last_commit: 'abc123' });
+ });
+
+ it('handles pull_complete with empty data', () => {
+ const event = { data: JSON.stringify({}) };
+ events.handlePullComplete(event);
+ expect(CloudPebble.GitHub.OnPullComplete).toHaveBeenCalledWith({});
+ });
+ });
+
+ describe('handlePullFailed', () => {
+ it('hides pending pill and calls OnPullFailed', () => {
+ events.handlePullFailed();
+ expect(CloudPebble.Sidebar.HidePending).toHaveBeenCalledWith('github');
+ expect(CloudPebble.GitHub.OnPullFailed).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleBuildStart', () => {
+ it('shows pending pill with Building text and calls OnBuildStart with build_id', () => {
+ const event = { data: JSON.stringify({ build_id: 42 }) };
+ events.handleBuildStart(event);
+ expect(CloudPebble.Sidebar.ShowPending).toHaveBeenCalledWith('compile', 'Building');
+ expect(CloudPebble.Compile.OnBuildStart).toHaveBeenCalledWith(42);
+ });
+
+ it('parses build_id as integer from JSON data', () => {
+ const event = { data: JSON.stringify({ build_id: 99 }) };
+ events.handleBuildStart(event);
+ expect(CloudPebble.Compile.OnBuildStart).toHaveBeenCalledWith(99);
+ });
+ });
+
+ describe('handleBuildComplete', () => {
+ it('hides pending pill and calls OnBuildComplete with build_id and state', () => {
+ const event = { data: JSON.stringify({ build_id: 42, state: 'succeeded' }) };
+ events.handleBuildComplete(event);
+ expect(CloudPebble.Sidebar.HidePending).toHaveBeenCalledWith('compile');
+ expect(CloudPebble.Compile.OnBuildComplete).toHaveBeenCalledWith(42, 'succeeded');
+ });
+
+ it('passes failed state correctly', () => {
+ const event = { data: JSON.stringify({ build_id: 7, state: 'failed' }) };
+ events.handleBuildComplete(event);
+ expect(CloudPebble.Compile.OnBuildComplete).toHaveBeenCalledWith(7, 'failed');
+ });
+ });
+});
\ No newline at end of file
diff --git a/cloudpebble/ide/static/ide/js/cloudpebble.js b/cloudpebble/ide/static/ide/js/cloudpebble.js
index 621617b..f102e16 100644
--- a/cloudpebble/ide/static/ide/js/cloudpebble.js
+++ b/cloudpebble/ide/static/ide/js/cloudpebble.js
@@ -93,6 +93,7 @@ CloudPebble.Init = function() {
CloudPebble.Publish.Init();
}
CloudPebble.FuzzyPrompt.Init();
+ CloudPebble.Events.Init();
CloudPebble.ProgressBar.Hide();
// Add source files.
diff --git a/cloudpebble/ide/static/ide/js/compile.js b/cloudpebble/ide/static/ide/js/compile.js
index d44f98e..54d3f25 100644
--- a/cloudpebble/ide/static/ide/js/compile.js
+++ b/cloudpebble/ide/static/ide/js/compile.js
@@ -947,6 +947,20 @@ var show_clear_logs_prompt = function() {
var opts = options || {};
var kind = opts.kind || CloudPebble.Compile.GetPlatformForInstall();
return install_on_watch(kind, opts.build);
+ },
+ OnBuildStart: function(buildId) {
+ mRunningBuild = true;
+ if (pane) {
+ var tempBuild = {started: (new Date()).toISOString(), finished: null, state: 1, uuid: null, id: buildId, size: {total: null, binary: null, resources: null}};
+ update_last_build(pane, tempBuild);
+ pane.find('#run-build-table').prepend(build_history_row(tempBuild));
+ }
+ },
+ OnBuildComplete: function(buildId, state) {
+ mRunningBuild = false;
+ if (pane) {
+ update_build_history(pane);
+ }
}
};
})();
diff --git a/cloudpebble/ide/static/ide/js/editor.js b/cloudpebble/ide/static/ide/js/editor.js
index 12c7a68..da5d185 100644
--- a/cloudpebble/ide/static/ide/js/editor.js
+++ b/cloudpebble/ide/static/ide/js/editor.js
@@ -519,12 +519,12 @@ CloudPebble.Editor = (function() {
var check_safe = function() {
return Ajax.Get('/ide/project/' + PROJECT_ID + '/source/' + file.id + '/is_safe?modified=' + file.lastModified).then(function(data) {
if(!data.safe) {
- if(was_clean) {
+ if(code_mirror.was_clean) {
code_mirror.setOption('readOnly', true);
return CloudPebble.Get('/ide/project/' + PROJECT_ID + '/source/' + file.id + '/load', function(data) {
code_mirror.setValue(data.source);
file.lastModified = data.modified;
- was_clean = true; // this will get reset to false by setValue.
+ code_mirror.was_clean = true; // this will get reset to false by setValue.
}).finally(function() {
code_mirror.setOption('readOnly', false);
});
@@ -552,24 +552,24 @@ CloudPebble.Editor = (function() {
}
},
onDestroy: function() {
- if(!was_clean) {
+ if(!code_mirror.was_clean) {
--unsaved_files;
}
delete open_codemirrors[file.id];
}
});
- var was_clean = true;
+ code_mirror.was_clean = true;
code_mirror.on('change', function() {
- if(was_clean) {
+ if(code_mirror.was_clean) {
CloudPebble.Sidebar.SetIcon('source-' + file.id, 'edit');
- was_clean = false;
+ code_mirror.was_clean = false;
++unsaved_files;
}
});
var mark_clean = function() {
- was_clean = true;
+ code_mirror.was_clean = true;
--unsaved_files;
CloudPebble.Sidebar.ClearIcon('source-' + file.id);
};
@@ -1284,6 +1284,18 @@ CloudPebble.Editor = (function() {
}
}
+ var get_active_source_file_id = function() {
+ var pane_id = $('#main-pane').data('pane-id');
+ if (!pane_id || pane_id.indexOf('source-') !== 0) {
+ return null;
+ }
+ var file_id = parseInt(pane_id.substring('source-'.length), 10);
+ if (isNaN(file_id)) {
+ return null;
+ }
+ return file_id;
+ };
+
return {
Create: function() {
create_source_file();
@@ -1311,6 +1323,37 @@ CloudPebble.Editor = (function() {
},
RenameFile: function(file, new_name) {
return rename_file(file, new_name)
+ },
+ /**
+ * Re-fetch the source of the currently-active editor from the server and
+ * replace its buffer. No-op if the active pane isn't a source file, if
+ * the editor is currently detached, or if the buffer has unsaved local
+ * edits (we never clobber user work).
+ *
+ * Called from Sidebar.Refresh after a GitHub pull, so the user doesn't
+ * end up staring at a stale buffer for a file that just changed on
+ * the server.
+ */
+ ReloadActive: function() {
+ var file_id = get_active_source_file_id();
+ if (file_id === null) {
+ return Promise.resolve();
+ }
+ var code_mirror = open_codemirrors[file_id];
+ if (!code_mirror) {
+ return Promise.resolve();
+ }
+ if (!code_mirror.was_clean) {
+ return Promise.resolve();
+ }
+ return Ajax.Get('/ide/project/' + PROJECT_ID + '/source/' + file_id + '/load').then(function(data) {
+ code_mirror.setValue(data.source);
+ code_mirror.was_clean = true;
+ var file = project_source_files[code_mirror.file_path];
+ if (file) {
+ file.lastModified = data.modified;
+ }
+ });
}
};
})();
diff --git a/cloudpebble/ide/static/ide/js/github.js b/cloudpebble/ide/static/ide/js/github.js
index 25d09f7..30bd341 100644
--- a/cloudpebble/ide/static/ide/js/github.js
+++ b/cloudpebble/ide/static/ide/js/github.js
@@ -36,6 +36,22 @@ CloudPebble.GitHub = (function() {
var enable_needy = function() {
pane.find('.github-actions').find('input, button').removeAttr('disabled');
};
+ var update_pull_mode_ui = function() {
+ var auto_pull = pane.find('#github-repo-hook').val() == '1';
+ var force_checkbox = pane.find('#github-repo-hook-force');
+ var help_text = pane.find('#github-repo-hook-help');
+ if (auto_pull) {
+ force_checkbox.removeAttr('disabled');
+ if (force_checkbox.is(':checked')) {
+ help_text.html(gettext("Auto-pull will wipe and re-import all files from GitHub every time you push. This is slower but more thorough than the default delta sync."));
+ } else {
+ help_text.html(gettext("Auto-pull will sync only the changed files from GitHub every time you push. This is fast but check the box above for a full re-import if you encounter issues."));
+ }
+ } else {
+ force_checkbox.attr('disabled', 'disabled');
+ help_text.html(gettext("You will need to pull from GitHub manually using the button below. Auto-pull is disabled."));
+ }
+ };
pane.find('#github-repo-form').submit(function(e) {
e.preventDefault();
@@ -44,6 +60,7 @@ CloudPebble.GitHub = (function() {
var repo_branch = pane.find('#github-branch').val();
var auto_pull = pane.find('#github-repo-hook').val() == '1';
var auto_build = pane.find('#github-repo-build').val() == '1';
+ var hook_force = pane.find('#github-repo-hook-force').is(':checked');
if(repo_branch == null || repo_branch.length == 0) {
repo_branch = "master";
@@ -51,7 +68,7 @@ CloudPebble.GitHub = (function() {
if((new_repo === CloudPebble.ProjectInfo.github.repo || !new_repo && !CloudPebble.ProjectInfo.github.repo) &&
(repo_branch === CloudPebble.ProjectInfo.github.branch || !repo_branch && !CloudPebble.ProjectInfo.github.branch) &&
- auto_pull === CloudPebble.ProjectInfo.github.auto_pull && auto_build === CloudPebble.ProjectInfo.github.auto_build) {
+ auto_pull === CloudPebble.ProjectInfo.github.auto_pull && auto_build === CloudPebble.ProjectInfo.github.auto_build && hook_force === CloudPebble.ProjectInfo.github.hook_force) {
show_alert('success', "Updated repo (nothing changed).");
return;
}
@@ -60,6 +77,7 @@ CloudPebble.GitHub = (function() {
repo: new_repo,
auto_pull: auto_pull ? '1' : '0',
auto_build: auto_build ? '1' : '0',
+ hook_force: hook_force ? '1' : '0',
branch: repo_branch
};
Ajax.Post('/ide/project/' + PROJECT_ID + '/github/repo', data).then(function(data) {
@@ -70,6 +88,7 @@ CloudPebble.GitHub = (function() {
CloudPebble.ProjectInfo.github.branch = repo_branch;
CloudPebble.ProjectInfo.github.auto_pull = auto_pull;
CloudPebble.ProjectInfo.github.auto_build = auto_build;
+ CloudPebble.ProjectInfo.github.hook_force = hook_force;
return;
}
if(!data.exists) {
@@ -104,6 +123,14 @@ CloudPebble.GitHub = (function() {
}
pane.find('#github-repo-hook').val(CloudPebble.ProjectInfo.github.auto_pull ? '1' : '0');
pane.find('#github-repo-build').val(CloudPebble.ProjectInfo.github.auto_build ? '1' : '0');
+ pane.find('#github-repo-hook-force').prop('checked', CloudPebble.ProjectInfo.github.hook_force);
+ pane.find('#github-repo-hook').on('change', update_pull_mode_ui);
+ pane.find('#github-repo-hook-force').on('change', update_pull_mode_ui);
+ update_pull_mode_ui();
+ var lastSync = CloudPebble.ProjectInfo.github.last_sync;
+ if(lastSync) {
+ pane.find('#github-last-sync').text(interpolate(gettext('Last synced: %s'), [lastSync]));
+ }
var prompt = $('#github-new-repo-prompt');
prompt.find('form').submit(function(e) {
@@ -137,6 +164,12 @@ CloudPebble.GitHub = (function() {
var prompt = $('#github-pull-prompt').modal();
prompt.find(".running").addClass('hide');
prompt.find(".close, .dire-warning, .modal-footer").removeClass("hide");
+ prompt.find('#github-pull-force').prop('checked', false);
+ prompt.find('#github-pull-force-warning').addClass('hide');
+ });
+
+ $(document).on('change', '#github-pull-force', function() {
+ $('#github-pull-force-warning').toggleClass('hide', !$(this).is(':checked'));
});
var poll_commit_status = function(task_id) {
@@ -156,13 +189,13 @@ CloudPebble.GitHub = (function() {
return Ajax.PollTask(task_id).then(function(result) {
if(result) {
show_alert('success', gettext("Pulled successfully."));
- alert(gettext("Pull completed successfully."));
- // *NASTY HACK: Make sure it doesn't think we have unsaved files, thereby
- // preventing page reload.
- CloudPebble.Editor.GetUnsavedFiles = function() { return 0; };
- window.location.reload(true);
} else {
- show_alert('success', gettext("Pull completed: Nothing to pull."));
+ var lastSync = CloudPebble.ProjectInfo.github.last_sync;
+ if(lastSync) {
+ show_alert('success', interpolate(gettext("Already up to date (last synced %s)."), [lastSync]));
+ } else {
+ show_alert('success', gettext("Pull completed: Nothing to pull."));
+ }
}
});
};
@@ -196,15 +229,14 @@ CloudPebble.GitHub = (function() {
$('#github-pull-prompt-confirm').click(function() {
disable_all();
var prompt = $('#github-pull-prompt');
- prompt.find(".close, .dire-warning, .modal-footer").addClass("hide");
- prompt.find(".running").removeClass('hide');
- Ajax.Post('/ide/project/' + PROJECT_ID + '/github/pull').then(function(data) {
+ prompt.modal('hide');
+ var forcePull = prompt.find('#github-pull-force').is(':checked') ? '1' : '0';
+ Ajax.Post('/ide/project/' + PROJECT_ID + '/github/pull', {force: forcePull}).then(function(data) {
return poll_pull_status(data.task_id);
}).catch(function(error) {
show_alert('error', interpolate(gettext("Pull failed: %s"), [error.message]));
}).finally(function() {
enable_all();
- prompt.modal('hide');
});
ga('send', 'event', 'github', 'pull');
});
@@ -221,6 +253,20 @@ CloudPebble.GitHub = (function() {
},
Show: function() {
show_github_pane();
+ },
+ OnPullStart: function() {
+ $('#github-push-btn, #github-pull-btn').attr('disabled', 'disabled');
+ $('#github-last-sync').text(gettext('Pulling from GitHub...'));
+ },
+ OnPullComplete: function(data) {
+ $('#github-push-btn, #github-pull-btn').removeAttr('disabled');
+ var lastSync = data && data.github_last_sync ? data.github_last_sync : '';
+ $('#github-last-sync').text(lastSync ? interpolate(gettext('Last synced: %s'), [lastSync]) : gettext('Pull completed.'));
+ CloudPebble.Sidebar.Refresh();
+ },
+ OnPullFailed: function() {
+ $('#github-push-btn, #github-pull-btn').removeAttr('disabled');
+ $('#github-last-sync').text(gettext('Pull failed.'));
}
};
})();
diff --git a/cloudpebble/ide/static/ide/js/sidebar.js b/cloudpebble/ide/static/ide/js/sidebar.js
index f4045d9..94cfbb3 100644
--- a/cloudpebble/ide/static/ide/js/sidebar.js
+++ b/cloudpebble/ide/static/ide/js/sidebar.js
@@ -243,8 +243,30 @@ CloudPebble.Sidebar = (function() {
a.find('i').removeClass().addClass('icon-' + icon);
},
+ ShowPending: function(pane_id, text) {
+ if (this._pendingTimers && this._pendingTimers[pane_id]) {
+ clearInterval(this._pendingTimers[pane_id]);
+ }
+ this.ClearIcon(pane_id);
+ var a = $('#sidebar-pane-' + pane_id).find('a');
+ var span = $('