(function(F/*the fossil object*/){
"use strict";
/**
Code for the /filepage app. Requires that the fossil JS
bootstrapping is complete and fossil.fetch() has been installed.
Custom events, handled via fossil.page.addEventListener():
- Event 'fileedit-file-loaded': passes on information when it
loads a file, in the form of an object:
{
filename: string,
checkin: UUID string,
isExe: bool,
mimetype: mimetype string, as determined by the fossil server.
}
The fossil.page.fileContent() method gets or sets the current file
content for the page.
- Event 'fileedit-content-replaced': when the editor's content is
replaced, as opposed to it being edited via user
interaction. This normally happens via selecting a file to
load. The event detail is the fossil.page object, not the current
file content.
- Event 'fileedit-preview-updated': when the preview is refreshed
from the server, this event passes on information about the preview
change in the form of an object:
{
element: the DOM element which contains the content preview.
mimetype: the fossil-reported content mimetype.
previewMode: a string describing the preview mode: see
the fossil.page.previewModes map for the values. This can
be used to determine whether, e.g., the content is suitable
for applying a 3rd-party code highlighting API to.
}
Here's an example which can be used with the highlightjs code
highlighter to update the highlighting when the preview is
refreshed in "wiki" mode (which includes fossil-native wiki and
markdown):
fossil.page.addEventListener(
'fileedit-preview-updated',
(ev)=>{
if(ev.detail.previewMode==='wiki'){
ev.detail.element.querySelectorAll(
'code[class^=language-]'
).forEach((e)=>hljs.highlightBlock(e));
}
}
);
*/
const E = (s)=>document.querySelector(s),
D = F.dom,
P = F.page;
/**
Widget for the checkin/file selection list.
*/
P.fileSelector = {
e:{
container: E('#fileedit-file-selector')
},
finfo: {},
cache: {
checkins: undefined,
files:{}
},
/**
Fetches the list of leaf checkins from the server and updates
the UI with that list.
*/
loadLeaves: function(){
D.append(D.clearElement(
this.e.ciListLabel,
this.e.selectCi,
this.e.selectFiles
),"Loading leaves...");
D.disable(this.e.btnLoadFile, this.e.selectFiles, this.e.selectCi);
const self = this;
F.fetch('fileedit',{
urlParams:'ajax=filelist&leaves',
responseType: 'json',
onload: function(list){
D.append(D.clearElement(self.e.ciListLabel),
"Open leaves (newest first):");
self.cache.checkins = list;
D.clearElement(D.enable(self.e.selectCi));
let loadThisOne;
list.forEach(function(o,n){
if(!n) loadThisOne = o;
D.option(self.e.selectCi, o.checkin,
o.timestamp+' ['+o.branch+']: '
+F.hashDigits(o.checkin));
});
self.loadFiles(loadThisOne ? loadThisOne.checkin : false);
}
});
},
/**
Loads the file list for the given checkin UUID. It uses a
cached copy on subsequent calls for the same UUID. If passed a
falsy value, it instead clears and disables the file selection
list.
*/
loadFiles: function(ciUuid){
delete this.finfo.filename;
this.finfo.checkin = ciUuid;
const selFiles = this.e.selectFiles;
if(!ciUuid){
D.clearElement(D.disable(selFiles, this.e.btnLoadFile));
return this;
}
const onload = (response)=>{
D.clearElement(selFiles);
D.append(
D.clearElement(this.e.fileListLabel),
"Editable files for ",
D.append(
D.code(), "[",
D.a(F.repoUrl('timeline',{
c: ciUuid
}), F.hashDigits(ciUuid)),"]"
), ":"
);
this.cache.files[response.checkin] = response;
response.editableFiles.forEach(function(fn,n){
D.option(selFiles, fn);
});
if(selFiles.options.length){
D.enable(selFiles, this.e.btnLoadFile);
}
};
const got = this.cache.files[ciUuid];
if(got){
onload(got);
return this;
}
D.disable(selFiles,this.e.btnLoadFile);
D.clearElement(selFiles);
D.append(D.clearElement(this.e.fileListLabel),
"Loading files for "+F.hashDigits(ciUuid)+"...");
F.fetch('fileedit',{
urlParams:{ajax:'filelist', checkin: ciUuid},
responseType: 'json',
onload
});
return this;
},
/**
Initializes the checkin/file selector widget. Must only be
called once.
*/
init: function(){
const selCi = this.e.selectCi = D.select(),
selFiles = this.e.selectFiles
= D.addClass(D.select(), 'file-list'),
btnLoad = this.e.btnLoadFile =
D.addClass(D.button("Load file"), "flex-shrink"),
filesLabel = this.e.fileListLabel =
D.addClass(D.div(),'flex-shrink','file-list-label'),
ciLabelWrapper = D.addClass(
D.div(), 'flex-container','flex-row', 'flex-shrink',
'stretch'
),
btnReload = D.addClass(
D.button('Reload'), 'flex-shrink'
),
ciLabel = this.e.ciListLabel =
D.addClass(D.span(),'flex-shrink','checkin-list-label')
;
D.attr(selCi, 'title',"The list of opened leaves.");
D.attr(selFiles, 'title',
"The list of editable files for the selected checkin.");
D.attr(btnLoad, 'title',
"Load the selected file into the editor.");
D.disable(selCi, selFiles, btnLoad);
D.attr(selFiles, 'size', 10);
D.append(
this.e.container,
D.append(ciLabelWrapper,
btnReload, ciLabel),
selCi,
filesLabel,
selFiles,
btnLoad
);
this.loadLeaves();
selCi.addEventListener(
'change', (e)=>this.loadFiles(e.target.value), false
);
btnLoad.addEventListener(
'click', (e)=>{
this.finfo.filename = selFiles.value;
if(this.finfo.filename){
P.loadFile(this.finfo.filename, this.finfo.checkin);
}
}, false
);
btnReload.addEventListener(
'click', (e)=>this.loadLeaves(), false
);
delete this.init;
}
}/*P.fileSelector*/;
/**
Internal workaround to select the current preview mode
and fire a change event if the value actually changes
or if forceEvent is truthy.
*/
P.selectPreviewMode = function(modeValue, forceEvent){
const s = this.e.selectPreviewMode;
if(!modeValue) modeValue = s.value;
else if(s.value != modeValue){
s.value = modeValue;
forceEvent = true;
}
if(forceEvent){
// Force UI update
s.dispatchEvent(new Event('change',{target:s}));
}
};
window.addEventListener("load", function() {
P.base = {tag: E('base')};
P.base.originalHref = P.base.tag.href;
P.tabs = new fossil.TabManager('#fileedit-tabs');
P.e = {
taEditor: E('#fileedit-content-editor'),
taCommentSmall: E('#fileedit-comment'),
taCommentBig: E('#fileedit-comment-big'),
ajaxContentTarget: E('#ajax-target'),
btnCommit: E("#fileedit-btn-commit"),
btnReload: E("#fileedit-tab-content > .fileedit-options > "
+"button.fileedit-content-reload"),
selectPreviewMode: E('#select-preview-mode select'),
selectHtmlEmsWrap: E('#select-preview-html-ems'),
selectEolWrap: E('#select-eol-style'),
selectEol: E('#select-eol-style select[name=eol]'),
selectFontSizeWrap: E('#select-font-size'),
selectDiffWS: E('select[name=diff_ws]'),
cbLineNumbersWrap: E('#cb-line-numbers'),
cbAutoPreview: E('#cb-preview-autoupdate > input[type=checkbox]'),
previewTarget: E('#fileedit-tab-preview-wrapper'),
diffTarget: E('#fileedit-tab-diff-wrapper'),
cbIsExe: E('input[type=checkbox][name=exec_bit]'),
fsFileVersionDetails: E('#file-version-details'),
tabs:{
content: E('#fileedit-tab-content'),
preview: E('#fileedit-tab-preview'),
diff: E('#fileedit-tab-diff'),
commit: E('#fileedit-tab-commit')
}
};
P.fileSelector.init();
/* Figure out which comment editor to show by default and
hide the other one. By default we take the one which does
not have the 'hidden' CSS class. If neither do, we default
to single-line mode. */
if(D.hasClass(P.e.taCommentSmall, 'hidden')){
P.e.taComment = P.e.taCommentBig;
}else if(D.hasClass(P.e.taCommentBig,'hidden')){
P.e.taComment = P.e.taCommentSmall;
}else{
P.e.taComment = P.e.taCommentSmall;
D.addClass(P.e.taCommentBig, 'hidden');
}
D.removeClass(P.e.taComment, 'hidden');
P.tabs.e.container.insertBefore(
/* Move the status bar between the tab buttons and
tab panels. Seems to be the best fit in terms of
functionality and visibility. */
E('#fossil-status-bar'), P.tabs.e.tabs
);
P.tabs.addEventListener(
/* Set up auto-refresh of the preview tab... */
'before-switch-to', function(ev){
if(ev.detail===P.e.tabs.preview){
P.baseHrefForFile();
if(P.e.cbAutoPreview.checked) P.preview();
}
}
);
P.tabs.addEventListener(
/* Set up auto-refresh of the preview tab... */
'before-switch-from', function(ev){
if(ev.detail===P.e.tabs.preview){
P.baseHrefRestore();
}
}
);
F.connectPagePreviewers(
P.e.tabs.preview.querySelector(
'#btn-preview-refresh'
)
);
const diffButtons = E('#fileedit-tab-diff-buttons');
diffButtons.querySelector('button.sbs').addEventListener(
"click",(e)=>P.diff(true), false
);
diffButtons.querySelector('button.unified').addEventListener(
"click",(e)=>P.diff(false), false
);
P.e.btnCommit.addEventListener(
"click",(e)=>P.commit(), false
);
F.confirmer(P.e.btnReload, {
confirmText: "Really reload, losing edits?",
onconfirm: (e)=>P.unstashContent().loadFile(),
ticks: 3
});
E('#comment-toggle').addEventListener(
"click",(e)=>P.toggleCommentMode(), false
);
P.e.taEditor.addEventListener(
'change', ()=>P.stashContentChange(), false
);
P.e.cbIsExe.addEventListener(
'change', ()=>P.stashContentChange(true), false
);
/**
Cosmetic: jump through some hoops to enable/disable
certain preview options depending on the current
preview mode...
*/
P.e.selectPreviewMode.addEventListener(
"change", function(e){
const mode = e.target.value,
name = P.previewModes[mode],
hide = [], unhide = [];
P.previewModes.current = name;
if('guess'===name){
unhide.push(P.e.cbLineNumbersWrap,
P.e.selectHtmlEmsWrap);
}else{
if('text'===name) unhide.push(P.e.cbLineNumbersWrap);
else hide.push(P.e.cbLineNumbersWrap);
if('htmlIframe'===name) unhide.push(P.e.selectHtmlEmsWrap);
else hide.push(P.e.selectHtmlEmsWrap);
}
hide.forEach((e)=>e.classList.add('hidden'));
unhide.forEach((e)=>e.classList.remove('hidden'));
}, false
);
P.selectPreviewMode(false, true);
const selectFontSize = E('select[name=editor_font_size]');
if(selectFontSize){
selectFontSize.addEventListener(
"change",function(e){
const ed = P.e.taEditor;
ed.className = ed.className.replace(
/\bfont-size-\d+/g, '' );
ed.classList.add('font-size-'+e.target.value);
}, false
);
selectFontSize.dispatchEvent(
// Force UI update
new Event('change',{target:selectFontSize})
);
}
if(0){ // only for testing
P.addEventListener(
'fileedit-file-loaded',
(e)=>console.debug('fileedit-file-loaded ==>',e)
);
}
P.addEventListener(
// Clear diff/preview when new content is loaded/set
'fileedit-content-replaced',
()=>D.clearElement(P.e.diffTarget, P.e.previewTarget)
);
/* Tell the user about which fossil.storage is being used... */
let storageMsg = D.addClass(D.div(),'flex-container','flex-row',
'fileedit-hint');
if(F.storage.isTransient()){
D.append(
D.addClass(storageMsg,'warning'),
"Warning: persistent storage is not avaible, "+
"so unsaved edits "+
"will not survive a page reload."
);
}else{
D.append(
storageMsg,
"Current storage mechanism for local edits: "+
F.storage.storageImplName()
);
}
P.e.tabs.content.insertBefore(storageMsg, P.e.tabs.content.lastElementChild);
}, false)/*onload event handler*/;
/**
Getter (if called with no args) or setter (if passed an arg) for
the current file content.
The setter form sets the content, dispatches a
'fileedit-content-replaced' event, and returns this object.
*/
P.fileContent = function f(){
if(0===arguments.length){
return f.get();
}else{
f.set(arguments[0] || '');
this.dispatchEvent('fileedit-content-replaced', this);
return this;
}
};
/* Default get/set impls for file content */
P.fileContent.get = function(){return P.e.taEditor.value};
P.fileContent.set = function(content){P.e.taEditor.value = content};
/**
For use when installing a custom editor widget. Pass it the
getter and setter callbacks to fetch resp. set the content of the
custom widget. They will be triggered via
P.fileContent(). Returns this object.
*/
P.setFileContentMethods = function(getter, setter){
this.fileContent.get = getter;
this.fileContent.set = setter;
return this;
};
/**
Removes the default editor widget (and any dependent elements)
from the DOM, adds the given element in its place, removes this
method from this object, and returns this object.
*/
P.replaceEditorElement = function(newEditor){
P.e.taEditor.parentNode.insertBefore(newEditor, P.e.taEditor);
P.e.taEditor.remove();
P.e.selectFontSizeWrap.remove();
delete this.replaceEditorElement;
return P;
};
/**
If either of...
- P.previewModes.current==='wiki'
- P.previewModes.current==='guess' AND the currently-loaded file
has a mimetype of "text/x-fossil-wiki" or "text/x-markdown".
... then this function updates the document's base.href to a
repo-relative /doc/{{this.finfo.checkin}}/{{directory part of
this.finfo.filename}}/
If neither of those conditions applies, this is a no-op.
*/
P.baseHrefForFile = function f(){
const fn = this.finfo ? this.finfo.filename : undefined;
if(!fn) return this;
if(!f.wikiMimeTypes){
f.wikiMimeTypes = ["text/x-fossil-wiki", "text/x-markdown"];
}
if('wiki'===P.previewModes.current
|| ('guess'===P.previewModes.current
&& f.wikiMimeTypes.indexOf(this.finfo.mimetype)>=0)){
const a = fn.split('/');
a.pop();
this.base.tag.href = F.repoUrl(
'doc/'+F.hashDigits(this.finfo.checkin)
+'/'+(a.length ? a.join('/')+'/' : '')
);
}
return this;
};
/**
Sets the document's base.href value to its page-load-time
setting.
*/
P.baseHrefRestore = function(){
P.base.tag.href = P.base.originalHref;
};
/**
Toggles between single- and multi-line comment
mode.
*/
P.toggleCommentMode = function(){
var s, h, c = this.e.taComment.value;
if(this.e.taComment === this.e.taCommentSmall){
s = this.e.taCommentBig;
h = this.e.taCommentSmall;
}else{
s = this.e.taCommentSmall;
h = this.e.taCommentBig;
/*
Doing (input[type=text].value = textarea.value) unfortunately
strips all newlines. To compensate we'll replace each EOL with
a space. Not ideal. If we were to instead escape them as \n,
and do the reverse when toggling again, then they would get
committed as escaped newlines if the user did not first switch
back to multi-line mode. We cannot blindly unescape the
newlines, in the off chance that the user actually enters \n
in the comment.
*/
c = c.replace(/\r?\n/g,' ');
}
s.value = c;
this.e.taComment = s;
D.addClass(h, 'hidden');
D.removeClass(s, 'hidden');
};
/**
Returns true if fossil.page.finfo is set, indicating that a file
has been loaded, else it reports an error and returns false.
If passed a truthy value any error message about not having
a file loaded is suppressed.
*/
const affirmHasFile = function(quiet){
if(!P.finfo){
if(!quiet) F.error("No file is loaded.");
}
return !!P.finfo;
};
/**
updateVersion() updates the filename and version in various UI
elements...
Returns this object.
*/
P.updateVersion = function(file,rev){
if(1===arguments.length){/*assume object*/
this.finfo = arguments[0];
file = this.finfo.filename;
rev = this.finfo.checkin;
}else if(0===arguments.length){
if(!affirmHasFile()) return this;
file = this.finfo.filename;
rev = this.finfo.checkin;
}else{
this.finfo = {filename:file,checkin:rev};
}
const eTgt = this.e.fsFileVersionDetails.querySelector('div'),
rHuman = F.hashDigits(rev),
rUrl = F.hashDigits(rev,true);
D.clearElement(eTgt);
D.append(
eTgt, "File: ",
D.append(D.code(),
D.a(F.repoUrl('finfo',{name:file, m:rUrl}), file)),
D.br()
);
D.append(
eTgt, "Checkin: ",
D.append(D.code(), D.a(F.repoUrl('info/'+rUrl), rHuman)),
" [",D.a(F.repoUrl('timeline',{m:rUrl}), "timeline"),"]",
D.br()
);
D.append(
eTgt, "Mimetype: ",
D.append(D.code(), this.finfo.mimetype||'???'),
D.br()
);
D.append(
eTgt,
D.append(D.code(), "[",
D.a(F.repoUrl('annotate',{filename:file, checkin:rUrl}),
'annotate'), "]"),
D.append(D.code(), "[",
D.a(F.repoUrl('blame',{filename:file, checkin:rUrl}),
'blame'), "]")
);
const purlArgs = F.encodeUrlArgs({
filename: this.finfo.filename,
checkin: rUrl
},false,true);
const purl = F.repoUrl('fileedit',purlArgs);
D.append(
eTgt,
D.append(D.code(),
"[",D.a(purl,"Editor permalink"),"]")
);
this.setPageTitle("Edit: "+this.finfo.filename);
return this;
};
/**
loadFile() loads (file,checkinVersion) and updates the relevant
UI elements to reflect the loaded state. If passed no arguments
then it re-uses the values from the currently-loaded file, reloading
it (emitting an error message if no file is loaded).
Returns this object, noting that the load is async. After loading
it triggers a 'fileedit-file-loaded' event, passing it
this.finfo.
*/
P.loadFile = function(file,rev){
if(0===arguments.length){
if(!affirmHasFile()) return this;
file = this.finfo.filename;
rev = this.finfo.checkin;
}
const self = this;
const onload = (r,headers)=>{
delete self.finfo;
self.updateVersion({
filename: file,
checkin: rev,
isExe: ('x'===headers['x-fileedit-file-perm']),
mimetype: headers['content-type'].split(';').shift()
});
self.tabs.switchToTab(self.e.tabs.content);
self.e.cbIsExe.checked = self.finfo.isExe;
self.fileContent(r);
self.dispatchEvent('fileedit-file-loaded', self.finfo);
};
const semiFinfo = {filename: file, checkin: rev};
const stashFinfo = this.getStashedFinfo(semiFinfo);
if(stashFinfo){ // fake a response from the stash...
this.finfo = stashFinfo;
this.e.cbIsExe.checked = !!stashFinfo.isExe;
onload(this.contentFromStash()||'',{
'x-fileedit-file-perm': stashFinfo.isExe ? 'x' : undefined,
'content-type': stashFinfo.mimetype
});
F.message("Fetched from the local-edit stash:",
F.hashDigits(stashFinfo.checkin),
stashFinfo.filename);
return this;
}
F.message(
"Loading content..."
).fetch('fileedit',{
urlParams: {
ajax: 'content',
filename:file,
checkin:rev
},
responseHeaders: ['x-fileedit-file-perm', 'content-type'],
onload:(r,headers)=>{
onload(r,headers);
F.message('Loaded content for',
F.hashDigits(self.finfo.checkin),
self.finfo.filename);
}
});
return this;
};
/**
Fetches the page preview based on the contents and settings of
this page's input fields, and updates the UI with with the
preview.
Returns this object, noting that the operation is async.
*/
P.preview = function f(switchToTab){
if(!affirmHasFile()) return this;
const target = this.e.previewTarget,
self = this;
const updateView = function(c){
D.clearElement(target);
if('string'===typeof c) target.innerHTML = c;
if(switchToTab) self.tabs.switchToTab(self.e.tabs.preview);
};
return this._postPreview(this.fileContent(), updateView);
};
/**
Callback for use with F.connectPagePreviewers()
*/
P._postPreview = function(content,callback){
if(!affirmHasFile()) return this;
if(!content){
callback(content);
return this;
}
const fd = new FormData();
fd.append('render_mode',this.e.selectPreviewMode.value);
fd.append('filename',this.finfo.filename);
fd.append('ln',E('[name=preview_ln]').checked ? 1 : 0);
fd.append('iframe_height', E('[name=preview_html_ems]').value);
fd.append('content',content || '');
F.message(
"Fetching preview..."
).fetch('fileedit',{
urlParams: {ajax: 'preview'},
payload: fd,
responseHeaders: 'x-fileedit-render-mode',
onload: (r,header)=>{
P.selectPreviewMode(P.previewModes[header]);
if('wiki'===header) P.baseHrefForFile();
else P.baseHrefRestore();
callback(r);
F.message('Updated preview.');
P.dispatchEvent('fileedit-preview-updated',{
previewMode: P.previewModes.current,
mimetype: P.finfo.mimetype,
element: P.e.previewTarget
});
},
onerror: (e)=>{
fossil.fetch.onerror(e);
callback("Error fetching preview: "+e);
}
});
return this;
};
/**
Fetches the content diff based on the contents and settings of
this page's input fields, and updates the UI with the diff view.
Returns this object, noting that the operation is async.
*/
P.diff = function f(sbs){
if(!affirmHasFile()) return this;
const content = this.fileContent(),
self = this,
target = this.e.diffTarget;
const fd = new FormData();
fd.append('filename',this.finfo.filename);
fd.append('checkin', this.finfo.checkin);
fd.append('sbs', sbs ? 1 : 0);
fd.append('content',content);
if(this.e.selectDiffWS) fd.append('ws',this.e.selectDiffWS.value);
F.message(
"Fetching diff..."
).fetch('fileedit',{
urlParams: {ajax: 'diff'},
payload: fd,
onload: function(c){
target.innerHTML = [
"<div>Diff <code>[",
self.finfo.checkin,
"]</code> → Local Edits</div>",
c||'No changes.'
].join('');
if(sbs) P.tweakSbsDiffs();
F.message('Updated diff.');
self.tabs.switchToTab(self.e.tabs.diff);
}
});
return this;
};
/**
Performs an async commit based on the form contents and updates
the UI.
Returns this object.
*/
P.commit = function f(){
if(!affirmHasFile()) return this;
const self = this;
const content = this.fileContent(),
target = document.querySelector('#fileedit-manifest'),
cbDryRun = E('[name=dry_run]'),
isDryRun = cbDryRun.checked,
filename = this.finfo.filename;
if(!f.onload){
f.onload = function(c){
const oldFinfo = JSON.parse(JSON.stringify(self.finfo))
target.innerHTML = [
"<h3>Manifest",
(c.dryRun?" (dry run)":""),
": ", F.hashDigits(c.checkin),"</h3>",
"<code class='fileedit-manifest'>",
c.manifest,
"</code></pre>"
].join('');
const msg = [
'Committed',
c.dryRun ? '(dry run)' : '',
'[', F.hashDigits(c.checkin) ,'].'
];
if(!c.dryRun){
if(0){
msg.push('Re-activating dry-run mode.');
cbDryRun.checked = true;
}
self.unstashContent(oldFinfo);
delete c.manifest;
self.finfo = c;
self.e.taComment.value = '';
self.updateVersion();
self.fileSelector.loadLeaves();
}
F.message.apply(F, msg);
self.tabs.switchToTab(self.e.tabs.commit);
};
}
const fd = new FormData();
fd.append('filename',filename);
fd.append('checkin', this.finfo.checkin);
fd.append('content',content);
fd.append('dry_run',isDryRun ? 1 : 0);
fd.append('eol', this.e.selectEol.value || 0);
/* Text fields or select lists... */
fd.append('comment', this.e.taComment.value);
if(0){
// Comment mimetype is currently not supported by the UI...
['comment_mimetype'
].forEach(function(name){
var e = E('[name='+name+']');
if(e) fd.append(name,e.value);
});
}
/* Checkboxes: */
['allow_fork',
'allow_older',
'exec_bit',
'allow_merge_conflict',
'prefer_delta'
].forEach(function(name){
var e = E('[name='+name+']');
if(e){
fd.append(name, e.checked ? 1 : 0);
}else{
console.error("Missing checkbox? name =",name);
}
});
F.message(
"Checking in..."
).fetch('fileedit',{
urlParams: {ajax: 'commit'},
payload: fd,
responseType: 'json',
onload: f.onload
});
return this;
};
/**
$stash is an internal-use-only object for managing "stashed"
local edits, to help avoid that users accidentally lose content
by switching tabs or following links or some such. The basic
theory of operation is...
All "stashed" state is stored using fossil.storage.
- When the current file content is modified by the user, the
current stathe of the current P.finfo and its the content
is stashed. For the built-in editor widget, "changes" is
notified via a 'change' event. For a client-side custom
widget, the client needs to call P.stashContentChange() when
their widget triggers the equivalent of a 'change' event.
- For certain non-content updates (as of this writing, only the
is-executable checkbox), only the P.finfo stash entry is
updated, not the content (unless the content has not yet been
stashed, in which case it is also stashed so that the stash
always has matching pairs of finfo/content).
- When saving, the stashed entry for the previous version is removed
from the stash.
- When "loading", we use any stashed state for the given
checkin/file combination. When forcing a re-load of content,
any stashed entry for that combination is removed from the
stash.
- Every time P.stashContentChange() updates the stash, it is
pruned to $stash.prune.defaultMaxCount most-recently-updated
entries.
- This API often refers to "finfo objects." Those are objects
with a minimum of {checkin,filename} properties (which must be
valid), and a combination of those two properties is used as
basis for the stash keys for any given checkin/filename
combination.
The structure of the stash is a bit convoluted for efficiency's
sake: we store a map of file info (finfo) objects separately from
those files' contents because otherwise we would be required to
JSONize/de-JSONize the file content when stashing/restoring it,
and that would be horribly inefficient (meaning "battery-consuming"
on mobile devices).
*/
const $stash = {
keys: {
index: F.page.name+':index'
},
/**
index: {
"CHECKIN_HASH:FILENAME": {file info w/o content}
...
}
In F.storage we...
- Store this.index under the key this.keys.index.
- Store each file's content under the key
(P.name+'/CHECKIN_HASH:FILENAME'). These are stored separately
from the index entries to avoid having to JSONize/de-JSONize
the content. The assumption/hope is that the browser can store
those records "directly," without any intermediary
encoding/decoding going on.
*/
indexKey: function(finfo){return finfo.checkin+':'+finfo.filename},
/** Returns the key for storing content for the given key suffix,
by prepending P.name to suffix. */
contentKey: function(suffix){return P.name+'/'+suffix},
/** Returns the index object, fetching it from the stash or creating
it anew on the first call. */
getIndex: function(){
if(!this.index) this.index = F.storage.getJSON(this.keys.index,{});
return this.index;
},
/**
Returns the stashed version, if any, for the given finfo object.
*/
getFinfo: function(finfo){
const ndx = this.getIndex();
return ndx[this.indexKey(finfo)];
},
/** Serializes this object's index to F.storage. Returns this. */
storeIndex: function(){
if(this.index) F.storage.setJSON(this.keys.index,this.index);
return this;
},
/** Updates the stash record for the given finfo
and (optionally) content. If passed 1 arg, only
the finfo stash is updated, else both the finfo
and its contents are (re-)stashed. Returns this.
*/
updateFile: function(finfo,content){
const ndx = this.getIndex(),
key = this.indexKey(finfo);
const record = ndx[key] || (ndx[key]={
checkin: finfo.checkin,
filename: finfo.filename,
mimetype: finfo.mimetype
});
record.isExe = !!finfo.isExe;
record.stashTime = new Date().getTime();
this.storeIndex();
if(arguments.length>1){
F.storage.set(this.contentKey(key), content);
}
return this;
},
/**
Returns the stashed content, if any, for the given finfo
object.
*/
stashedContent: function(finfo){
return F.storage.get(this.contentKey(this.indexKey(finfo)));
},
/** Returns true if we have stashed content for the given finfo
record. */
hasStashedContent: function(finfo){
return F.storage.contains(this.contentKey(this.indexKey(finfo)));
},
/** Unstashes the given finfo record and its content.
Returns this. */
unstash: function(finfo){
const ndx = this.getIndex(),
key = this.indexKey(finfo);
delete finfo.stashTime;
delete ndx[key];
F.storage.remove(this.contentKey(key));
return this.storeIndex();
},
/**
Clears all $stash entries from F.storage. Returns this.
*/
clear: function(){
const ndx = this.getIndex(),
self = this;
Object.keys(ndx).forEach(function(k){
const e = ndx[k];
delete ndx[k];
F.storage.remove(self.contentKey(k));
});
F.storage.remove(this.keys.index);
delete this.index;
return this;
},
/**
Removes all but the maxCount most-recently-updated stash
entries, where maxCount defaults to this.prune.defaultMaxCount.
*/
prune: function f(maxCount){
const ndx = this.getIndex();
const li = [];
if(!maxCount || maxCount<0) maxCount = f.defaultMaxCount;
Object.keys(ndx).forEach((k)=>li.push(ndx[k]));
li.sort((l,r)=>l.stashTime - r.stashTime);
while(li.length>maxCount){
const e = li.shift();
this.unstash(e);
console.warn("Pruned oldest stash entry:",e);
}
}
};
$stash.prune.defaultMaxCount = 7;
/**
Updates P.finfo for certain state and stashes P.finfo, with the
current content fetched via P.fileContent().
If passed truthy AND the stash already has stashed content for
the current file, only the stashed finfo record is updated, else
both the finfo and content are updated.
*/
P.stashContentChange = function(onlyFinfo){
if(affirmHasFile(true)){
const fi = this.finfo;
fi.isExe = this.e.cbIsExe.checked;
if(onlyFinfo && $stash.hasStashedContent(fi)){
$stash.updateFile(fi);
}else{
$stash.updateFile(fi, P.fileContent());
}
F.message("Stashed change to",F.hashDigits(fi.checkin),fi.filename);
$stash.prune();
}
return this;
};
/**
Removes any stashed state for the current P.finfo (if set) from
F.storage. Returns this.
*/
P.unstashContent = function(){
const finfo = arguments[0] || this.finfo;
if(finfo){
$stash.unstash(finfo);
//console.debug("Unstashed",finfo);
F.message("Unstashed",F.hashDigits(finfo.checkin),finfo.filename);
}
return this;
};
/**
Clears all stashed file state from F.storage. Returns this.
*/
P.clearStash = function(){
$stash.clear();
return this;
};
/**
If stashed content for P.finfo exists, it is returned, else
undefined is returned.
*/
P.contentFromStash = function(){
return affirmHasFile(true) ? $stash.stashedContent(this.finfo) : undefined;
};
/**
If a stashed version of the given finfo object exists (same
filename/checkin values), return it, else return undefined.
*/
P.getStashedFinfo = function(finfo){
return $stash.getFinfo(finfo);
};
P.$stash = $stash /*only for testing/debugging */;
})(window.fossil);