<|start_filename|>src/modules/list/main.js<|end_filename|>
'use strict';

/*
yes, this whole script is being re-executed from scratch every time the browser action item is clicked.
same with the html containing it, and everything.
*/
const logger = new Logger('',app.isDev());
logger.log('list.js');

/**
 * *everything*, i.e. table with requests, buttons etc.
 */
class List{
    constructor(){
        //queryTab: 'all': all tabs (usually in full mode). OR <integer>: specific id
        this.queryTab = null;

        this.port = null;
        this.mode = null;

        //Map of all Entry objects by unique key, for this tab.
        //**Referenced** in e.g. table.js
        this.entries = new Map();

        //these are the 'visible' ones, e.g. after filtering. 
        this.entriesVisible = new Map();

        //lightdom element
        this.ltable = null;

        //table.js object:
        this.table = null;

        //details.js object:
        this.details = null;

        //element (lightdom)
        this.lstats = null;

        //if 'true' we'll not show the loader, because apparently we're done.
        this.disableNextLoader = false;
    }

    initAndRun(){
        this.extensionManifest = chrome.runtime.getManifest();

        /**
         * mode: taken from the url (?mode=xyz), 'popup' means we're started from browser action icon, 
         * hence showing only current tab requests,
         * 'full' means we're in full mode (full window). We might show given specific tab OR global traffic.
         */
        var url = new URL(window.location.href);

        this.mode = url.searchParams.get("mode");
        if(this.mode == null){
            console.error('mode is not defined');
            this.mode = 'popup';//default one.
        }

        l('#header-app-info').text(`${this.extensionManifest.name} v. ${this.extensionManifest.version}`);

        //which tab should we filter here?

        //possible tabId in url (or 'all' or nothing)
        let urlTab = url.searchParams.get("tab");
        //we want to know 'our' tab id regardless mode (popup or full) or tab defined in url
        //so we put test scenarios here:
        chrome.tabs.query({currentWindow: true, active: true},tab => {
            if(!tab[0]){
                console.error('tab[0] is undefined o.O')
                return;//impossible
            }
            const curTab = tab[0];
            if(urlTab != null){
                this.queryTab = urlTab;
            }else{
                if(this.mode === 'popup'){
                    this.queryTab = curTab.id;
                }else{
                    this.queryTab = 'all';
                }
            }
            this.run();
        });;
    }

    run(){
        this.lstats  = l('#stats');
        this.ltable = l('#entries-table')
        //#entries-table - <table> declared in index.html
        this.table = new Table(this.ltable[0]);
        const columns = [
            //display, request|reponse, key, css class (optional)
            ['ID','request','requestId','slim'],
            //['URL','request','url'],
            ['Requested domain','special','host','url-domain'],
            ['Requested name','special','pathNameSmart','url-name'],
            ['Method','request','method','slim'],
            ['Status','response','statusCode', 'slim'],
            ['Type','request','type','type'],
            ['Time','special','time','slim'],
        ];
        //specific stuff if in 'global' mode.
        if(this.queryTab == 'all'){
            //inserting another column
            columns.splice(1, 0, 
                ['Tab (initiator)','special','tabTitle','tab-title'],
            );
        }
        this.table.setColumns(columns);

        this.table.make();
        //setting reference to our entries map
        //this.table.setEntries(this.entries);

        //communication        

        this.port = chrome.runtime.connect({name:'watcher'});

        this.port.onMessage.addListener(message => {
            this.onMessage(message);
        });

        //used for subscribing and maybe other things (e.g. clearing this tabs' log)
        let criteria = {};
        if(this.queryTab !== 'all'){
            criteria.tabId = this.queryTab;
        }

        //getting requests made up until now.
        // this.port.postMessage({
        //     command:'queryEntries',
        //     criteria: criteria,
        //     callbackData: {'action' : 'readEntries'},//we'll get this one when called back.
        // });
        this.readEntries();
        this.readExtraInfo();
        //subscribing for the next events (requests), which will probably come:
        this.port.postMessage({
            command:'subscribe',
            criteria: criteria,
        }); 
        
        //interface

        //buttons for clearing, closing, opening new netmon tools.
        
        //global monitor vs specific tab monitor
        if(this.queryTab === 'all'){//global
            l('#showGlobal').css('display','none');    
            l('#clearTab').css('display','none'); 
            l('#blocking').css('display','none'); 
            l('#filter-show-archived-wrapper').css('display','none'); 
        }else{//specific tab
            l('#clearAll').css('display','none'); 
        }

        //these are about 'full window' vs 'popup', not about reading a particular tab or not.
        if(this.mode === 'full'){
            l('#popOut').css('display','none');  
        }

        if(this.mode === 'popup'){
            l('#clearAll').css('display','none');  
        }

        l('#showGlobal').on('click',()=>{
            chrome.tabs.create({url:chrome.extension.getURL("modules/list/index.html?mode=full")}, function(tab) {
            });
            window.close();
        })        
    
        l('#popOut').on('click',()=>{
            window.open(chrome.extension.getURL(`modules/list/index.html?mode=full&tab=${this.queryTab}`));
            window.close();
        })          
        
        l('#clearTab, #clearAll').on('click',(ev)=>{
            let clearCriteria = ev.currentTarget.matches('#clearTab') ? criteria : {};
            //console.log('clear crits:',clearCriteria);
            this.port.postMessage({
                command:'clearEntries',
                criteria: clearCriteria,
            });            
        });

      

        //filtering
        l('#filter-text').on('input',(ev) => {
            this.updateTable();
            this.updateStatus();
        });

        l('#filter-show-archived').on('change',(ev) => {
            //this one requires full reread. p.s. this one will take care about updating as well.
            this.readEntries();
        });
        
        //play/pause (state) button 
        l('#running').on('click',(ev)=>{
            const lbutton = l(ev.currentTarget);
            const lbuttonIcon = lbutton.find('.fa-icon');
            if(!lbutton.is('.pressed')){//in 'pause' mode, we enable playing
                lbutton.addClass('pressed');
                //lbutton.text('⏸️️');
                lbuttonIcon.removeClass('fa-play').addClass('fa-pause');
                //rereading the data which might have came in the meantime
                this.clearData();
                this.readEntries();                
            }else{//in 'playing' mode, we pause it.
                //lbutton.text('▶️');
                lbuttonIcon.removeClass('fa-pause').addClass('fa-play');
                lbutton.removeClass('pressed');
            }
            
        });        
        //block (state) button 
        l('#blocking').on('click',(ev)=>{
            const lbutton = l(ev.currentTarget);
            if(!lbutton.is('.pressed')){//in non blocking mode (default), we'll start blocking
                lbutton.addClass('pressed').addClass('pressed-highlight');
                this.port.postMessage({
                    command:'block',
                    criteria: criteria,
                });                  
            }else{//in blocking mode, so we'll unblock it
                lbutton.removeClass('pressed').removeClass('pressed-highlight');
                this.port.postMessage({
                    command:'unblock',
                    criteria: criteria,
                });                 
            }            
        });
        //tab info (e.g. color)
        const lTabInfo = l('#tabinfo');
        let tabInfoText = '';
        let color = '';
        if(this.queryTab === 'all'){
            tabInfoText = 'Global scope';
            color = 'rgb(0,0,0)';
        }else{//we are tab specific
            //tabInfoText = 'Tab: ' + this.queryTab;
            tabInfoText = 'Tab scope';
            color = utils.distinctColorFromNumber(parseInt(this.queryTab));
        }
        lTabInfo.text(tabInfoText);
        lTabInfo.css ({
            'border-left':`8px solid ${color}`,
            'padding-left':'3px',
        });
        if(app.isDev()){
            lTabInfo.attr('title',`[tabId: ${this.queryTab}]`);
        }        

        //////////////////
        //details dialog or div        

        this.details = new EntryDetails(l('#modal-entry-details .content')[0]);
        const lTableBody =  l('#entries-table tbody');
        lTableBody.on('click',ev => {
            const lTarget = l(ev.target);
            //details popup
            if(lTarget.is('#entries-table tbody tr *')){
                const requestKey = lTarget.closest('tr').attr('data-requestkey');
                const entry = this.entries.get(requestKey);
                if(!entry){
                    return;
                }
                l('#modal-entry-details').addClass('modal-visible');

                this.details.setEntry(entry);                 
            }
        })

        //close button in *any* modal dialog:    
        l('.modal .modal-btn-close, .modal .modal-btn-ok').on('click',(ev)=>{
            l(ev.target).closest('.modal').removeClass('modal-visible');
        })

        //escape key - closing our (only right now) modal dialog
        l(document).on('keydown',(ev)=>{
            //l(ev.target).closest('.modal').removeClass('modal-visible');
            if(ev.keyCode === 27){
                l('#modal-entry-details').removeClass('modal-visible');
            }
            
        })        

    }

    /**
     * getting all requests. Used on initial start or on some ocassions, like notification
     * from background.js about e.g. clearing log.
     */
    readEntries(){
        
        let criteria = {};
        if(this.queryTab !== 'all'){
            criteria.tabId = this.queryTab;

        }
        if(l('#filter-show-archived')[0].checked){
            //this will make background.js use the 'entries' rather than tabEntries, but filtered
            //by tabId
            criteria.useGlobal = true;
        }
        //getting requests made up until now.
        simplePerformance.mark('sending queryEntries msg');
        this.port.postMessage({
            command:'queryEntries',
            criteria: criteria,
            callbackData: {'action' : 'readEntries'},//we'll get this one when called back (in this.onMessage)
        });
    }

    /**
     * Clears data, both 'original' (here) and in the table object. 
     * Does not clear filters.
     */ 
    clearData(){
        this.entries.clear();
        this.table.remove(false,true);
    }

    //stats, like internal one (this.stats) but also related to specific tab 
    //(e.g. number of auto removed entries). This will eventually make run the this.updateExtraInfo()
    readExtraInfo(){
        let criteria = {};
        if(this.queryTab !== 'all'){
            criteria.tabId = this.queryTab;

        }
        this.port.postMessage({
            command:'getExtra',
            criteria: criteria,
            //callbackData: {'action' : 'onReadExtraInfo'},//we'll get this one when called back.
        });        
    }

    //inResponseTo === 'getStats') for example number of auto-removed entries.
    updateExtraInfo(extras){
        this.extraInfo = extras;
        //are we actually blocking something? Just to make the icon look right
        if(extras.tabExtra.blocking){
            l('#blocking').addClass('pressed').addClass('pressed-highlight');
        }
        if(extras.tabExtra.autoRemovedEntriesCount > 0){
            l('#entries-removed-warning').text(` Note: ${extras.tabExtra.autoRemovedEntriesCount} oldest records removed.`).attr('title','To avoid performance issues');
        }
    }

    //message (callback) from our port (probably from background js in response to our 'command')
    onMessage(message){
        //console.log('onMessage:',message);

        //callback in response to *our* readEntries query
        if(message.callbackData && message.callbackData.action === 'readEntries' ){
            
            simplePerformance.mark('received entries');
            this.entries.clear();
            this.entriesVisible.clear();

            //we need to convert an array to a map. This needs to be an array initially, because there are
            //some issues with chrome when sending a Map from background.js
            //this [k,v] means take first value of subarray to 'k' and the other to 'v', and btw, 'k' is a 
            //request id and v is the well, whole entry.
            message.result.forEach(([k,v])=>{
                //if it's our own tab, i.e. our 'global view' tab - like 
                //"chrome-extension://gkgkbnhnimkgahhagocmeimjbgjneidp/modules/list/index.html?mode=full"
                //we ignore it. It's just easier to do it here than in background.js. And, btw, FX doesn't
                //send this requests at all.
                if(v.extra.tab && v.extra.tab.url && v.extra.tab.url.indexOf('chrome-extension://') === 0){
                    return;
                }
                let entry = new Entry();
                Object.assign(entry,v);//this actually could be moved to an Entry constructor.
                this.entries.set(k,entry);
            });
            
            simplePerformance.mark('built entries');
            //>500 entries will take quite a few ms, so we'll show our 'loader'
            if(this.entries.size > 500){
                this.updateLoader(true,`loading ${this.entries.size} entries...`);
            }
            //timeout is there to allow a browser displaying a (possible) loader info. 
            setTimeout(() => {
                this.updateTable();
                this.updateStatus();
                simplePerformance.mark('updated table');
                //logger.log(simplePerformance.getReport());
                //do not show delayed loader 
                //this.disableNextLoader=true;
                this.updateLoader(false);        
            }, 50);
            
            
            
            
            simplePerformance.clearMarks();

        }
        if(message.command === 'callback' && message.inResponseTo === 'getExtra'){
            this.updateExtraInfo(message.result);
        }
        //our 'subscription' callback from background js' monitor
        if(message.command === 'subscription'){
            this.onSubscription(message.eventName,message.details);
        }
       
    }

    //requests going *live* while we're opened
    //we'll add a request to this->entries *and* will notify this.table to add a row.
    //btw, this.table has a *reference* to this->entries, but here, when doing 'addRow' it doesn't
    //make any use of this fact.
    onSubscription(eventName, details){
        
        //logger.log('onSubscription:',Date.now(), eventName, details);
        if(eventName === 'onBeforeRequest'){
            const entry = new Entry();

            //In contrast to the other on..., here in 'details' a whole entry is sent. 
            //With request, empty reponse, some extras 
            //this actually could be moved to a Entry constructor.
            Object.assign(entry,JSON.parse(JSON.stringify(details)));

            if(entry.extra.tab && entry.extra.tab.url && entry.extra.tab.url.indexOf('chrome-extension://') === 0){
                return;
            }                        
            this.entries.set(getKey(details.request), entry);
            
            if(this.isPlaying() && this.matches(entry, this.getFilters())){
                this.table.addRow(entry);
            }
        }else if (eventName === 'onSendHeaders'){
            const entry = this.entries.get(getKey(details.request));
            if(entry){
                //entry['request']['headers'] = details.requestHeaders;	            
                Object.assign(entry,JSON.parse(JSON.stringify(details)));
            }
        }else if (eventName === 'onCompleted' || eventName === 'onErrorOccurred'){
            const entry = this.entries.get(getKey(details.response));
            if(entry){
                //entry.response = details;
                Object.assign(entry,JSON.parse(JSON.stringify(details)));
                if(this.isPlaying()){
                    this.table.updateRow(entry);
                    //regardless matching or not (because we might want to update 'y' in "x/y requests", i.e. total)
                    this.updateStatus();
                }
            }else{
                //this is actually possible if for some reason we did not add a request, for example 
                //because it came from ourselves (chrome-extension)
                //logger.log('warning, no entry in list view for ' + details.request.requestId);
            }
        }else if (
            eventName === 'entriesClearedNotification' || 
            eventName === 'entriesAutoClearedNotification'
           ){
            //logger.log('cleared!: ', eventName);
            //either autocleariing or user somewhere did a *manual* cleaning, we don't care here if it's us or not, 
            //we'll just reread.
            if(this.isPlaying()){
                this.clearData();
                this.readEntries();
            }
            
        }                
    }

    /**
     * (Re)Updates *whole* table, based on our .entries AND filters.
     * Used on initial startup and when filters change. 
     * However, when the 'archived' filter changes, we actually resend the whole 
     * 'query' command to the background.
     */
    updateTable(){
        //removing table body rows:
        this.table.remove(false,true);

        //we store it in entriesVisible for future reference (e.g. in this.updateStatus). 
        //If there is no filtering then this.entriesVisible will be an exact copy of this.entries
        this.entriesVisible = this.getFiltered(this.entries);
        //readding (or adding for the first time, doesn't matter)
        this.table.addRows(this.entriesVisible);
    }

    updateStatus(){
        let statusString = '';
        const entriesTotal = this.entries.size;
        const entriesVisible = this.entriesVisible.size;//@todo - change that when filtering implemented
        if(entriesTotal === entriesVisible){
            statusString = `${entriesTotal} requests`;
        }else{
            statusString = `${entriesVisible}/${entriesTotal} requests`;
        }
        //ms
        let totalTime = 0;
        this.entriesVisible.forEach(entry => {
            if(entry.extra.time){
                totalTime += entry.extra.time;
            }
        });
        totalTime = utils.formatTimeSpan(totalTime,2);
        statusString += ` | ${totalTime}`;
        this.lstats.text(statusString);
    }

    /*
    * show / hide loader (spinner, or just a word "loading....")
    * @param {bool} show  
    * */
    updateLoader(show, text = null){
        if(show && this.disableNextLoader){
            this.disableNextLoader = false;
            return;
        }
        const lloader = l('#loader');
        lloader.css({'display':show?'block':'none'});
        lloader.text(text?text:'');
    }

    /**
     * true if the 'play' (running) button is pressed.
     */
    isPlaying(){
        return l('#running').is('.pressed');
    }

    /**
     * Based on GUI prepares and object with filters, like 'text'. Also 'active' which tells if there is 
     * any filtering at all
     */
    getFilters(){
        let filters = {
            active: false,//one or more of the filters is active?
            text: '',//original one (although trimmed)
            textRegExp: null, //RegExp object (or null) -  prepared for actual regex test.
            //in future versions here we'll have  maybe mimetype etc.
        }

        filters.text = document.querySelector('#filter-text').value;
        filters.text = filters.text.trim();

        
        if( filters.text != ''){
            filters.active = true;
            //is it wrapped in // ? Like '/blah.*/' - then user wants to treat it as a regex. 
            if(utils.hasRegexDelimeters(filters.text)){
                //we'll use only what's inside the / and / - just like it is.
                filters.textRegExp = new RegExp(filters.text.substring(1, filters.text.length-1), "i");    
            }else{//otherwise we'll escape things like ? or * to do literal matching.
                filters.textRegExp = new RegExp(utils.escapeRegExp(filters.text), "i");
            }
        }

        return filters;
    };

    /**
     * Tells if an entry matches given filters
     * @param {*} entry - request object
     * @param {*} filters - filters object returned by this.getFilters
     */
    matches(entry, filters){
        if (!filters.active){
            return true;
        }

        //ok, there is at least one filter active. All tests must pass.
        let result = true;
        //text filter
        if(filters.textRegExp && !filters.textRegExp.test(entry.request.url)){
            result = false;
        }
        //and so on.... 
        return result;
    }

    /**
     * Filters given Map by using the filters in the interface
     * Returns exactly the same 'entries' map in case there are no filters active
     * Otherwise returns a modified copy. 
     */
    getFiltered(entries){
        const filters = this.getFilters();

        //no filtering at all, we return *now* with no further processing (for performance reasons, we just have
        //nothing to do here)
        if (!filters.active){
            return entries;
        }

        //ok, there is at least one filter active
        const resultEntries = new Map();

        var t0 = performance.now();
        entries.forEach((entry, key) => {
 
            if(this.matches(entry, filters)){
                resultEntries.set(key, entry);
            }
        });
        var t1 = performance.now();
        //console.log("Call to do regex filtering took " + (t1 - t0) + " milliseconds.");

        return resultEntries;
    }

   
    //logging on behalf on background.js - easier to observe it than with 'inspect popup' on chrome
    //yeah, pretty akward.
    logBkg(param){
        chrome.runtime.sendMessage({command:'logPlease',from: `popup tabId: ${this.queryTab}`,'param': param});
    }
}

//this one (list object) is here only because it's easier to debug stuff from the console in a browser, otherwise 
//it could as well be in the below anon. funk.
const list = new List();
l(function (){
    list.initAndRun();
})

function getMonitor(){
    return monitor;
}

<|start_filename|>src/common/simplePerformance.js<|end_filename|>
/*
Just a simpler way to measure execution time of ... things
http://jsfiddle.net/kpion/e0pw9kvb/

Usage:
console.clear();
simplePerformance.mark('mark 1');    
//doing some lengthy operation.....
console.log(simplePerformance.mark('mark 2')); //this will store the 'mark 2' mark and 
also print the time since the previous mark.
console.log(simplePerformance.getReport());//times of specific marks.
*/
const simplePerformance = {
    
    //array of [<markName>,<timeInMs>]
    marks: [], 
    
    /**
     * adds a 'mark' and returns a time passed since the last one 
     */
    mark(name = ''){
        const ret = this.time();
        this.marks.push([name,this.now()]);
        return ret;
    },
    
    /** 
     * returns time from the last mark (to *now*)
     */ 
    time(){
        if(this.marks.length === 0){//no marks
            return 0;
        }
        return this.now() - this.marks[this.marks.length -1][1];
    },
    
    /**
     * 'custom' version of .now()
     */ 
    now(){
        if(typeof performance === 'object'){
            return performance.now();
        }
        return Date.now();
    },
    
    /**
     * times of specific marks.
     */
    getReport(){
        let report = [];
        let prev = -1;
        this.marks.forEach(m => {
            let passed = prev == -1? 0: m[1]-prev;
            report.push({mark:m[0],'time':passed});
            prev = m[1];
        })
        return report;
    },    
    getMarks(){
        return this.marks;
    },
    clearMarks(){
        this.marks = [];
    }
    
}

<|start_filename|>src/background/tabs.js<|end_filename|>

/**
 * As *current* as possible state of all tabs opened in all browser windows.
 * 'title', 'url', and active tab.
 * 
 * Made for quick access for tab info, without quering the browser (with chrome.tabs.query or similar)
 * every single time we have a request.
 * 
 * We cache the tabs info in three ways: 
 * 1. webNavigation.onDOMContentLoaded - this calls update() method which queries the browser for the tab info
 * 2. just in case we missed some info using way #1 (e.g. because the extension was just installed now), 
 * 	the getOrQuery() method will also call the update() function (which queries the browser) but 
 * 	**only** when the tab is missing in our cache
 * 3. the webNavigation.onBeforeNavigate (which btw happens before onDOMContentLoaded) is there to 
 * 	actually **clear** this tab info, because the tab title might now be obsolote, but it updates the url (which
 * 	is fresh).
 */
class Tabs{

	constructor(){

		//all the info as it is when doing chrome.tabs.query({}...)
		//indexed under tab.id
		//we don't want new Map, because we don't care about the order and other features.
		this.tabs = {};
		this.activeTab = {};
		this.updateAll();
		
		//onBeforeNavigate starts very soon, just before opening the url. The url is known already, but 
		//tab's title is not. We still want to handle this for this exact reason - claring the tab's title
		//so the monitor will not display an obsolete one.

		//this is one of the 3 methods in this class to obtain tab info
		chrome.webNavigation.onBeforeNavigate.addListener((details) => {
			//console.log('on before:',details);
			if(details.frameId === 0){//main frame, not some sub iframes
				if(this.is(details.tabId)){
					//the title is now (probably) obsolote, it's better to simply clear it. So we won't display it.
					this.tabs[details.tabId].title = '';
					//this one on the other hand is quite up to date.
					this.tabs[details.tabId].url = details.url;
				}
			}
		});

		//we now know the tab's title. And maybe other things as well.
		chrome.webNavigation.onDOMContentLoaded.addListener((details) => {
			if(details.frameId === 0){//main frame, not some sub iframes
				this.update(details.tabId);//btw, only a fraction shorter than simply this.updateAll
			}
		});

		chrome.webNavigation.onTabReplaced.addListener((details) => {
			this.updateAll();//only a few miliseconds for 20 tabs on not-so-decent cpu, no worries
		});

		chrome.tabs.onRemoved.addListener((details) => {
			this.updateAll();//only a few miliseconds for 20 tabs on not-so-decent cpu, no worries
		});

		/////////////////////////////////////////////////
		//currently active
		chrome.tabs.onActivated.addListener((details) => {
			//we don't have too much info in details, and this one will take care about 
			//this.activeTab and we get same info as in this.tabs
			this.updateAll();
		});
	}
	
	/**
	 * removes and then re-adds all tabs info. Async.
	 */
	updateAll(callback = null){
		chrome.tabs.query({},tabs => {
			//removing old
			this.tabs = {};
			tabs.forEach(tab => {
				this.tabs[tab.id] = tab;
				if(tab.active){
					this.activeTab = tab;
				}
			});
			if(callback){
				callback();
			}
		});;
	}

	/**
	 * updates one tab in our cache, plus calls a callback with the fresh info. 
	 * usage: .update(111,tab => {...just one tab object...})
	 */
	update(tabId, callback = null){
		chrome.tabs.get(tabId, tab=>{
			this.tabs[tabId] = tab;
			if(callback){
				callback(tab);
			}
		});
	}
	/**
	 * 
	 * true if we have this tab id. Not really needed, but meh.
	 */
	is(tabId){
		return tabId in this.tabs;
	}	

	/**
	 * returns given tab in our *cache*, or null if missing.
	 * will not query the browser for this info.
	 */
	get(tabId){
		if(tabId in this.tabs === false) {
			return null;
		}
		return this.tabs[tabId];
	}

	/**
	 * gets (via callback!) a tab info from the our cache and if not found, queries 
	 * the browser for it and stores in cache for future reference.
	 * A tab could be not in the cache when e.g. appeared recently
	 * usage: .getOrQuery(111,tab=>{...tab is a single object...});
	 */
	getOrQuery(tabId, callback){
		let existing = this.get(tabId);
		if(existing){
			if(callback){
				callback(existing);
			}
			return;
		}
		//there is none like this, might be removed, or maybe it has just appeared recently
		//this one will query the browser and update our cache. And call the callback.
		this.update(tabId, callback);
	}

	getActiveTab(){
		return this.activeTab;
	}

	/**
	 * Tells if this is 'our' tab, i.e. belonging to the extension.
	 * Based on the tab's URL. Extension's base url should be found there on position 0.
	 * @param {object | int} tab - tabId or a tab object (e.g. one of the this.tabs items)
	 */
	isOur(tab){
		tab = typeof tab === 'object' ? tab : this.get(tab);
		if(!tab || !tab.url){
			return false;
		}
		return tab.url.indexOf(chrome.runtime.getURL('')) === 0;
	}
}


<|start_filename|>src/lib/lightdom.js<|end_filename|>
/*
LightDom 2.1 

Lightweight querySelectorAll wrapper and DOM manipulation clas, inheriting from the Array class.

https://github.com/kpion/lightdom

*/

(function (window) {
 
    class LightDom extends Array{

        constructor(parameter = null, context = null) {
            super();
            this.context = context || document;
            this.add(parameter,context);
        }

        //internal use only
        add(parameter, context = null){
            let nodes = null;//used only if adding from array / other LightDom instance
            if (typeof parameter === 'string' && parameter !== '') {
                //Object.assign(this, Array.from(this.context.querySelectorAll(parameter)));
                parameter = parameter.trim();
                if (parameter[0] === '<') {//(string)html
                    nodes = Array.from(this.create(parameter));
                }else{                
                    nodes = Array.from(this.context.querySelectorAll(parameter));
                }
            } else if (parameter instanceof Node) {//includes HTMLElement, Element, Node etc
                this.push (parameter);
            } else if (parameter instanceof NodeList || parameter instanceof HTMLCollection || parameter instanceof Array) {
                //Object.assign(this, Array.from(parameter));
                nodes = Array.from(parameter);
            } else if (parameter instanceof LightDom) {
                //copying ourselves to ourselves
                //Object.assign(this, parameter);
                nodes = parameter;
            } else if (typeof parameter === 'function') {
                //callback to call when document is ready (DOMContentLoaded or immediately if already loaded)
                this.ready(parameter);
            } else{
                //acceptable in certain situations only, like calling e.g. l().setLogging(false);
            }
            if(nodes){//only if adding from array / other LightDom instance
                if(this.length === 0){
                    Object.assign(this, nodes);
                }else{
                    nodes.forEach(el => this.push(el));
                }
            }
            return this;
        }

        //`each` which wraps every single element in LightDom
        each(callback){
            this.forEach(node => callback(new LightDom(node)));
            return this;
        }

        //our version, only difference is that we return "this".
        forEach(callback){
            super.forEach(callback);
            return this;
        }



        filter(parameter = null){
            if(typeof parameter === 'string'){
                return new LightDom(super.filter(el => el.matches(parameter)));
            }
            if(parameter === null){
                return new LightDom(this);
            }
            //must be a function:
            return new LightDom(super.filter(parameter));
            
        }

        //only for those who expect this method here. Because we are just an array.
        get(index){
            if(typeof index === 'undefined'){//this is what jquery does too.
                return this;
            }
            return this[index];
        }

        // Find all the nodes CHILDREN of the current ones, matched by a selector
        find (parameter) {
           let result = new LightDom();
           this.forEach(node => {
               result.add(node.querySelectorAll(parameter))
           })
           return result;
        };

        //Get (unique) parents of all nodes.
        parent () {
            let result = new LightDom();
            this.forEach(node => {
                if(!result.includes (node.parentNode)){
                   result.add(node.parentNode)
                }
            })
            return result;
        };

        // Get the closest (by selector) parents of all nodes
        closest (parameter) {
            let result = new LightDom();
            this.forEach(node => {
                while((node = node.parentNode) && (node !== document)){
                    if(node.matches (parameter) && !result.includes (node)){
                        result.add(node);
                        break;
                    }
                }
            })
            return result;
        };

        is(parameter){
            return this.some(node => node.matches(parameter));
        }

        css(property,val = null){
            if(val === null && typeof property !== 'object'){//reading
                return this[0] ? this[0].style[property] : null;
            }
            //setting:
            const properties = (typeof property == 'object')?property:{[property]: val};
            return this.forEach(node => {
                Object.assign(node.style,properties);
            })
        }

        attr(property,val = null){
            if(val === null && typeof property !== 'object'){//reading
                return this[0] ? this[0].getAttribute(property) : null;
            }
            //setting:
            const properties = (typeof property == 'object')?property:{[property]: val};
            return this.forEach(node => {
                Object.entries(properties).forEach( ([prop,val]) => {node.setAttribute (prop,val)})
            })
        }       
        
        addClass(name){
            return this.forEach(node => {
                node.classList.add (name);
            })            
        }

        removeClass(name){
            return this.forEach(node => {
                node.classList.remove (name);
            })            
        }

        toggleClass(name){
            return this.forEach(node => {
                node.classList.toggle (name);
            })            
        }        

        html(val = null){
            if(val === null){
                return this[0] ? this[0].innerHTML : '';
            }
            return this.forEach(node => {
                node.innerHTML = val;
            })            
        }

        empty(){
            return this.html('');
        }

        text(val = null){
            if(val === null){
                return this[0] ? this[0].textContent : '';
            }
            return this.forEach(node => {
                node.textContent = val;
            })            
        }     

        insertAdjacentHTML(position, html){
            return this.forEach(node => {
                node.insertAdjacentHTML (position, html);
            })              
        }
 
        append(param){
            return this._insertElement (param, 'beforeend', 'append');
        }

        //alias for .append
        appendChild(param){
            return this.append(param);
        }

        prepend(param){
            return this._insertElement (param, 'afterbegin', 'prepend');
        }

        before(param){
            return this._insertElement (param, 'beforebegin', 'before');
        } 

        after(param){
            return this._insertElement (param, 'afterend', 'after');
        }        

        //creates and returns new element(s) from (string)html (does not add them here), rather for internal use only
        create(html){
            //we can't use document.createDocumentFragment(); because it doesn't have .innerHTML
            var div = document.createElement('div');
            div.innerHTML = html;
            return new LightDom (div.childNodes);
        }

        on(type, callback, options = false){
            return this.forEach(node => {
                node.addEventListener(type, callback, options);
           });
        }
        
        //this one will just work on a document regardless of nodes in our collection
        ready(callback){
            let callbackEvent = ()=>{
                callback();
                document.removeEventListener("DOMContentLoaded",callbackEvent);
            };
            if (document.readyState === "loading") {//might be 'loading' or 'complete'
                document.addEventListener("DOMContentLoaded", callbackEvent);
            
            } else {//dom already loaded, so the above event will never fire, so:  
                callback();
            }
            return this;
        }

         /** 
         * [INTERNAL] A very generic (internal) method. Will insert a string html or an element (node); 
         * Depending on the type of the argument.
         * @param param - whatever (html, node, nodelist, another lightdom etc)
         * @param position - only when param is (string)html -  one of insertAdjacentHTML 'position' values - like 'beforeend' etc
         * @param action - only when param is nodelist, lightdom etc, one of methods like 'append', 'prepend', 'before', 'after'
         */ 
        _insertElement(param, position, action){
            //a (string) HTML
            if(typeof param === 'string'){
                return this.insertAdjacentHTML(position, param);
            }
            //we are going with the .append .prepend etc. 
            let lSrc = param instanceof LightDom ? param : new LightDom(param);
            //if this is just a single Node, for optimization sake we'll just insert it, 
            //we don't need playing with documentFragment for this single Node
            if(lSrc.length === 1){
                return this.forEach(node => {
                    node[action](lSrc[0].cloneNode(true));
                 }) 
            }
            //preparing whole fragment to insert later:
            let fragment = document.createDocumentFragment();
            lSrc.forEach(srcElem => {
                fragment.appendChild(srcElem.cloneNode(true));
            })
            //actual inserting:
            return this.forEach(node => {
               node[action](fragment.cloneNode(true));
            })       
        }      

    }

    /////////
    //End of class LightDom definition

    function lightdom(parameter, context = null) {
        return new LightDom(parameter, context);
    }


    //finally, it will be available under l:
    window.l = lightdom;


})(window);


<|start_filename|>src/background/background.js<|end_filename|>
'use strict';
const logger = new Logger('',app.isDev());


//
logger.log('netmon started');

class Monitor{
	constructor (){
		//only for information purposes. Timestamp in ms.
		this.startTime = Date.now();

		//to get a name or something
		this.extensionManifest = chrome.runtime.getManifest();

		//statsKeys - debug / reporting thing only.
		//*some* of the keys used in this.stats (used by this.addStat) Just those repeating or... something.
		//this object has 'our' internal name, used in this.addStat, and 'real' name which later 
		//will be used to display it.
		this.statsKeys = {
			//entries remove in this.entries for any reason. Revmoing *Only* from one tab doesn't count.
			totalEntriesRemoved: 'Total entries removed (any reason)'
		};
		this.init();
	}

	//might be called multiple times (e.g. to reset):
	init(){
		//Map. all Entry objects by unique key, absolutely all entries, no dividing into tabs, with main key being
		this.entries = new Map();

		//Map of Maps - by tabId we have then a map of entries by unique key (independent from the this.entries)
		//again, this is NOT a map to the above 'entries', it's completely independent.
		this.tabEntries = new Map();

		//Array of objects with *Ports* and criteria (like tabId), see onMessage.
		//So a single item object might be like:
		// {
		// 	port : port,
		// 	criteria: {tabId:2232}, 
		// }
		this.subscribers = [];

		//this one servers only some non-very-important information about e.g. tab title, url etc.
		this.tabs = new Tabs();

		//Indexed under tabId, some additional stuff per tab, *our* stuff, e.g. by Monitor. 
		//Like e.g. number of removed entries in a particular tab, so we can display it for a user.
		//or 'block' status.
		//not stored in the above this.tabs because it's not the Tabs class responsibility.
		this.tabsExtras = {};

		//internal log, for debugging and stuff. Consists of objects with 
		//{ message: and data: }
		//use this.log();
		this.logs = [];

		//statistics, mostly for internal (debugging) stuff, like 'total times' spent on something.
		//use this.addStat('key',numberToAdd);
		this.stats = {};
	}

	//alias for .init
	reset(){
		this.init();
	}

	run(){
		const filter = { urls: [ "<all_urls>" ] };

		//////////////////////////////////
		//tabs
		chrome.tabs.onActivated.addListener(
			(details) => {this.onTabActivated(details)});

		chrome.webNavigation.onBeforeNavigate.addListener(
			(details) => {this.onWebNavigationStart(details)});

		
		/* this one is too late
		chrome.webNavigation.onCommitted.addListener(
			(details) => {this.onWebNavigationCommitted(details)}, 
			filter);
		*/			
		//////////////////////////////////
		//requests, in the order of appearance
		chrome.webRequest.onBeforeRequest.addListener(
			(details) => {return this.onBeforeRequest(details)}, 
			filter,["requestBody","blocking"]);

		//it seems we need this one as well, even if we have onBeforeRequest, because
		//onBeforeRequest does not accept requestHeaders as an extra option, and this one - onSendHeaders
		//does not accept requestBody o.O
		//anyway, these are requests headers. Just btw: there is also onBeforeSendHeaders but we don't want it,
		//because subsequent extensions might modify stuff later. While this one here is the 'actual' one which will
		//really be sent.
		chrome.webRequest.onSendHeaders.addListener(
			(details) => {this.onSendHeaders(details)},
			filter,["requestHeaders"]);



		//onHeadersReceived i.e. response headers.
		//here we can theoretically modify the headers, which we don't want to, so we'll just
		//handle the onCompleted below.
		/*
		chrome.webRequest.onHeadersReceived.addListener(
			(details) => {this.onHeadersReceived(details)}, 
			filter,["responseHeaders"]
		);
		*/

		//Listen for responses to requests that get HTTP-redirected
		chrome.webRequest.onBeforeRedirect.addListener(
			(details) => {this.onCompleted(details, true)},
			filter,["responseHeaders"]);

		//end of everything I guess... i.e. onCompleted and onErrorOccurred below.
		//btw, the responseHeaders flag tells the browser to send responseHeaders which here (in onCompleted)
		//seems to be identical with those in onHeadersReceived (although maybe this changes if there are some 
		//other extensions modyfing those.)
		chrome.webRequest.onCompleted.addListener(
			(details) => {this.onCompleted(details)}, 
			filter,["responseHeaders"]);

		/**
		 * This is **not** an error from server, like 404 or 50x, it's e.g. a connection error:
		 * onErrorOccurred : Fired when a request could not be processed due to an error: for example, a lack of Internet connectivity.
		 * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onErrorOccurred
		 * when this happens, the 'error' key of the `details` is defined, e.g.:
		 * error: "net::ERR_INTERNET_DISCONNECTED"
		 * first we'll get (for any given request): onBeforeRequest, then onSendHeaders, and finally onErrorOccurred.  
		 */  	
		chrome.webRequest.onErrorOccurred.addListener(
			(details) => {this.onErrorOccurred(details)}, 
			filter);

		///////////////////////////////////////////////////////////////////////////
		//communication 
		//this is established e.g. every time a user opens a browser action popup.
		//later the client probably will call 'subscribe'

		chrome.runtime.onConnect.addListener(port => {

			//called everytime a popup is opened (or full mode list)
			port.onMessage.addListener(message => {
				this.onMessage(port, message);
			})

			//called everytime a popup is closed (or full mode list)
			port.onDisconnect.addListener(something => {
				//removing subscriber (added by this.onMessage):
				for(var n in this.subscribers){
					const subscriber = this.subscribers[n];
					if(subscriber.port === port){
						this.subscribers.splice(n, 1);
						//logging only:
						let dbgTabId = 'none';
						if(subscriber.criteria && subscriber.criteria.tabId){
							dbgTabId = subscriber.criteria.tabId;
						}
						//logger.log('subscriber removed, tabId: ' + dbgTabId);
						//break;
					}
				}
			})

		});		
		
		//auto clearing
		setInterval(() => {
			this.autoClear();
		}, 1000*60);
	};

	/**
	 * called everytime user click another tab, 
	 * @param {object} details : {tabId: integer, windowId: integer}
	 */
	onTabActivated(details){
		//logger.log('onTabActivated: ', details);
		this.updateBrowserActionIcon(details.tabId);
	};

	/**
	 * updates 'browser action' icon, i.e. badge text and maybe the icon itself.
	 * @param {int}	optional tabId, if null we'll use current tab.
	 */
	updateBrowserActionIcon(tabId = null){
		if(tabId === null){
			//we need to ask about it (async).
			chrome.tabs.query({currentWindow: true, active: true},tab => {
				if(!tab[0]){
					console.error('tab[0] is undefined o.O')
					return;//impossible. Update: actually seems possible when the current window was... dev tools.
				}
				this.updateBrowserActionIcon(tab[0].id);
				return;
			});
		}		
		const tab = this.tabs.get(tabId);	
		const tabExtra = this.getTabExtra(tabId);

		let badgeText = '0';
		//title as displayed on mouse hover over the icon.
		let titleText = this.extensionManifest.name;
		if(tabExtra.blocking){
			titleText += ' (blocking) ';
		}
		if(this.tabEntries.has(tabId)){
			const theTabEntries = this.tabEntries.get(tabId);
			if(this.tabs.isOur(tabId)){
				//is this extension's tab?
				//badgeText = this.entries.size;
				badgeText = '';//ehhh, for consistency with situation below.
			}else if (!tab || !tab.url){
				//this might happen when it's *we*, our extension tab, which was opened
				//just now. Sometimes this happens, randomly
				badgeText = '';
			}else{
				//'normal' situation
				//used to add a '>' char in case there were some entries autoremoved, but it doesn't fit
				//the space.
				badgeText = theTabEntries.size;
				if(theTabEntries.size > 0){
					const lastEntry = Array.from(theTabEntries)[theTabEntries.size - 1][1];
					const tabInfo = app.isDev()?` [from tab ${tabId}]`:'';
					if(tabExtra.autoRemovedEntriesCount > 0){
						titleText += ': >';	
					}else{
						titleText += ': ';	
					}
					titleText += `${theTabEntries.size} requests${tabInfo}`;
					if(tabExtra.autoRemovedEntriesCount > 0){
						titleText += `\nNote: ${tabExtra.autoRemovedEntriesCount} oldest were removed from the list`;
					}
					titleText += `\nMost recent one: ` + utils.ellipsis(lastEntry.request.url,150);
				}
			}
		}
		chrome.browserAction.setBadgeText({ text: badgeText.toString() });
		if(!tabExtra.blocking){//NOT blocking
			chrome.browserAction.setBadgeBackgroundColor({color: '#626262'});
		}else{//Blocking
			chrome.browserAction.setBadgeBackgroundColor({color: '#b04242'});
		}
		//chrome.browserAction.setBadgeTextColor({color: '#fff'});//no such thing in chrome. Only fx.

		chrome.browserAction.setTitle({title: titleText});
		
		
		// if (timesCurrentlyDoing > 0) {
		// 	chrome.browserAction.setIcon({ path: 'static/on.gif' });
		// } else {
		// 	chrome.browserAction.setIcon({ path: 'static/off.png' });
		// }		
	}
	/**
	 * onWebNavigationStart
	 * started when user refreshes an existing tab in any way or
	 * creates a new tab (with ctrl+t or ctrl+link click)
	 * we want to remove 'old' entries from this.tabEntries but *not* from entries, so they 
	 * can still be actually available if needed.
	 * separately for every f... iframe. when frameId it's the main one.
	 * @todo - this should be configurable ('persistent' checkbox somewhere, or maybe not)
	 * 
	 * @param {object} details: {
	 * 	tabId: integer, 
	 *  frameId: 0 for the main one, positive integer for consecutive ones) 	
	 *  timeStamp : 1540666413914.113 (example) - so miliseconds from unix epoch (float?)
	 */
	onWebNavigationStart(details){
		//logger.log('onWebNavigationStart: ', details);

		//removing "old" tabEntries, i.e. older than "now".
		//thanks to that, the very next initialQuery (or any other query) to this particular tab 
		//will give only the requests following this reset.
		if(details.frameId === 0){//main frame, not some sub iframes
			if(this.tabEntries.has(details.tabId)){
				//this *could* work: this.tabEntries.delete(details.tabId);
				//but it can happen that onWebNavigationStart will fire after firstRequest. Fortunatelly
				//timestamps are telling the truth:
				const theTabEntries = this.tabEntries.get(details.tabId);
				theTabEntries.forEach((entry,key) => {
					if(entry.request && entry.request.timeStamp){
						if(entry.request.timeStamp < details.timeStamp){
							//logger.log('removing: ',entry);

							//only in the tabEntries because this is where we don't want the 'old' to show, we
							//still want them in 'general view'.
							theTabEntries.delete(key);
							//bu we'l remember in *entries* the time user navigated away, so we will remove it in some
							//point of time. readme: problem-old-entries
							const theEntry = this.entries.get(key);
							if(theEntry){
								theEntry.extra.archivedOn = Date.now();
							}
						}else{
							//logger.log('leaving alive: ',entry);
						}
					}
				})
			}
		}
		//logger.log('----end of navstart');
	};
	/* too late
	onWebNavigationCommitted(details){
		logger.log('onWebNavigationCommitted: ', details);
	}
	*/

	/** 
	 * On **all** the requests.
	 * @param {string} eventName e.g. 'onBeforeRequest
	 * @param {Entry | Object} - i.e. the Entry instance or any other object
	*/
	onWebRequest(eventName, details){
		//logger.log('on: ' +  eventName + ' : ' + (details.url || '') + ':',details);
		//if the event is onBeforeRequest (first event in the chain of given request) 
		//then exceptionally details is a whole entry. Otherwise it's just request/response headers or other
		//detail.
		let tabId = null;
		if(details instanceof Entry){
			tabId = details.request.tabId;
		}else{
			tabId = details.tabId;
		}
		this.notifySubscribers(tabId, eventName, details);
	}

	/**
	 * The moment a new 'entry' is born. 
	 * Here we actually do add an 'entry' to the list. 
	 */
	onBeforeRequest(details){
		//logger.log('onBeforeRequest for ' + details.url,details);

		//this isn't an ordinary tab, probably it's ourselves (-1) and so we don't want to pollute the lists.
		if(details.tabId <= 0){
			return;
		}
		
		const tabExtra = this.getTabExtra(details.tabId);

		const entry = new Entry();
		entry.request = details;
		if(tabExtra.blocking){
			entry.extra.blocked = true;
		}
		this.entries.set(getKey(details),entry);
		
		//and... *independly*, tabEntries.
		//first of all - do we have a `tab` collection there?
		if(!this.tabEntries.has(details.tabId)){
			this.tabEntries.set(details.tabId, new Map());
		}
		
		this.tabEntries.
			get(details.tabId).
			set(getKey(details), entry);

		
		//Grabbing tab information.
		//We probably already have this tab in our cache, in this case this will be a moment (sync)
		//But in case user has just opened a link in a new tab, we don't have this tab information
		//And so we'll need to do it async, by quering the tab. 
		//And only then do the onWebRequest which will notify subscribers.
		this.tabs.getOrQuery(details.tabId,tab => {
			//logger.log('...sending only now:', Date.now(), entry);
			entry.extra.tab.url = tab.url;
			entry.extra.tab.title = tab.title;
			
			//here, in contrast to the rest of the on... events, we send the *whole* entry, not only 
			//'details' (btw - it used to be like this)
			this.onWebRequest('onBeforeRequest',entry);
			
		})

		//if this request is in currently active tab, we update the icon. 
		if(details.tabId === this.tabs.getActiveTab().id){
			this.updateBrowserActionIcon(details.tabId);
		}

		this.addStat('Total requests ever reported',1);
		
		if(tabExtra.blocking === true){
			this.addStat('Total requests blocked',1);
			return {"cancel": true};
		}
	}

	//request headers.
	onSendHeaders(details){
		//logger.log('onSendHeaders',details);
		//this isn't an ordinary tab, probably it's ourselves (-1) and so we don't want to pollute the lists.
		if(details.tabId <= 0){
			return;
		}
		const entry = this.entries.get(getKey(details));
		//might be that user did 'clear' while this request was still going on.
		if(!entry){
			logger.log('no entry for ' + details.requestId + ':',details);
			return;
		}		
		entry['request']['headers'] = details.requestHeaders;
		//this.onWebRequest('onSendHeaders',details);
		this.onWebRequest('onSendHeaders',entry);
	}
	/*
	onHeadersReceived(details){
		this.onWebRequest('onHeadersReceived',details);	
	}*/

	onCompleted(details, isRedirect=false){
		//this isn't an ordinary tab, probably it's ourselves (-1) and so we don't want to pollute the lists.
		if(details.tabId <= 0){
			return;
		}
		const entry = this.entries.get(getKey(details));

		//no such thing yet (or already) not sure why but this ... happens
		//maybe it's a matter of deleting entries (clear) while there are still on going ones.
		if(!entry){
			logger.log('no entry for ' + details.requestId + ':',details);
			return;
		}			
		//this.entries[details.requestId]['response'] = details;

		//renaming 'responseHeaders' to simply 'headers' for consistency
		details['headers'] = details.responseHeaders;
		delete details['responseHeaders'];

		//setting:
		entry.response = details;
		if (isRedirect) entry.response.isRedirect = true;
		entry.extra.time = entry.response.timeStamp - entry.request.timeStamp;

		//this.onWebRequest('onCompleted',details);
		this.onWebRequest('onCompleted',entry);
	}

	onErrorOccurred(details){
		//this isn't an ordinary tab, probably it's ourselves (-1) and so we don't want to pollute the lists.
		if(details.tabId <= 0){
			return;
		}
		const entry = this.entries.get(getKey(details));
		//no such thing yet (or already), not sure why but this ... happens
		if(!entry){
			logger.log('no entry for ' + details.requestId + ':',details);
			return;
		}	
		entry.response = details;
		entry.extra.time = entry.response.timeStamp - entry.request.timeStamp;
		//this.onWebRequest('onErrorOccurred',details);
		this.onWebRequest('onErrorOccurred',entry);
	}

	/**
	 * returns entries by search critieria
	 * @param {object} criteria
	 * 	if null, *all* entries are returned keyed by getKey()
	 *  otherwise: 
	 *  criteria { 
	 *    	tabId : if not null, only the entries for given tabId are returned as a Map(!)
	 *    	requestKey : if not null, only the entry for given unique key are return
	 * 			(no problem there is both above are defined)
	 * 		useGlobal: if true, the global this.entries will be used. Along with tabId this 
	 * 			will also give 'archived' entries for given tab
	 *  }
	 * 
	 */
	query(criteria = {}){
		if(criteria == null || Object.keys(criteria).length === 0){
			return this.entries;
		}

		//this is a weird scenario - we should use 'global' this.entries but no tabId given
		//in result we also will return everything this.entries.
		if(criteria && criteria.useGlobal && !criteria.tabId){
			return this.entries;
		}
		//first phase src entries:
		let srcEntries = null;//usually map, unless nothing found then an empty object {}
		if(typeof criteria.tabId !== 'undefined'){
			//is tabId valid integer?
			console.assert(parseInt(criteria.tabId) == criteria.tabId);

			//do we want 'all' or 'current' i.e. after most recent onWebNavigationStart, from given tab?
			
			if(criteria.useGlobal){
				//we want all for given tab
				srcEntries = this.getGlobalEntriesByTabId(criteria.tabId);
			}else{
				//we want only 'current'
				srcEntries = this.tabEntries.get(parseInt(criteria.tabId));
			}
		};
		
		//even if both (requestKey and tabId) are defined, that is fine, because given requestId
		//series can belong to only one tab anyway. 
		if(typeof criteria.requestKey !== 'undefined'){
			srcEntries = this.entries.get(criteria.requestKey);
		};

		if(typeof srcEntries === 'undefined'){
			return {};
		}
		return srcEntries;

	}

	getGlobalEntriesByTabId(tabId){
		const resultMap = new Map();
		this.entries.forEach((entry,requestId) => {
			//we use == not === because actually tabId isn't guaranteed to be int.
			if(entry.request && entry.request.tabId == tabId){
				resultMap.set(getKey(entry),entry);
			}
		})
		return resultMap;
	}
	/**
	 * One time message from our communication port, probably a command like a query or something.
	 * setup in this.run
	 * There is also this.subscribers which receive regular messages related to requests
	 */
	onMessage(port, message){
		
		if (message.command === "subscribe"){
			/*
			logger.log('new subscriber, tabId: ' + message.tabId);
			this.tabsubscribers[message.tabId] = {
				port: port,
			};*/
			//logger.log('new subscriber:', Date.now(), message.criteria);
			this.subscribers.push({
				port : port,
				criteria: message.criteria, 
			});
		}
		if (message.command === "unsubscribe"){
			//port.onDisconnect.addListener ... somewhere here should be enough.
			/*
			if(this.tabsubscribers[message.tabId]){
				delete this.tabsubscribers[message.tabId];
			}*/
		}
		
		if (message.command === "queryEntries"){
			let entries = this.query(message.criteria || {});
			port.postMessage({
				command: 'callback', 
				callbackData: typeof message.callbackData == 'undefined'? {} : message.callbackData,
				inResponseTo: message.command,

				result: Array.from(entries), 
				errorCode: 0
			});
		}
		//stats, like internal one (this.stats) but also related to specific tab 
		//(e.g. number of auto removed entries, is blocking or not)
		if (message.command === "getExtra"){
			//let entries = this.query(message.criteria || {});
			const result = {};
			result.internalStats = this.getStats();
			result.tabExtra = {};
			if(message.criteria && message.criteria.tabId){
				result.tabExtra = this.getTabExtra(message.criteria.tabId);
			}
			port.postMessage({
				command: 'callback', 
				callbackData: typeof message.callbackData == 'undefined'? {} : message.callbackData,
				inResponseTo: message.command,

				result: result, 
				errorCode: 0
			});
		}
		//'clear' button (or clear all)
		if (message.command === 'clearEntries'){
			//for stats
			let removedCount = 0;
			//Is this clearing a specific tab only?
			if(message.criteria && message.criteria.tabId)	{
				let tabId = parseInt(message.criteria.tabId);
				//logger.log('clearing tab id: ' + tabId);
				//ensuring that really was an integer. Map seems to be strongly typed o.O
				console.assert(tabId == message.criteria.tabId);
				
				//this will get the entries from the given tab, grab their requestId and remove 
				//items this.entries by requestId and then the tabEntries itself.
				removedCount = this.removeTabEntries(tabId);
				this.updateBrowserActionIcon(tabId);
			}
			else{
				//removing absolutely everything
				//logger.log('clearing absolutely everything');
				removedCount = this.entries.size;
				this.tabEntries.clear();
				this.entries.clear();
				this.updateBrowserActionIcon();
			}
			//notifing subscribers.
			this.notifySubscribers(null,'entriesClearedNotification',{});
			//stats
			this.addStat('Entries removed by user ("clear" buttons)',removedCount)
			this.addStat(this.statsKeys.totalEntriesRemoved, removedCount);
		}

		//blocking requests
		if (message.command === 'block'){
			//are we supposed to block only specifi tab?
			if(message.criteria && message.criteria.tabId)	{
				this.getTabExtra(message.criteria.tabId).blocking = true;
				this.updateBrowserActionIcon(message.criteria.tabId);//just to reflect this
				logger.log('blocking ' + message.criteria.tabId);
			}
		}
		//unblocking requests
		if (message.command === 'unblock'){
			//are we supposed to unblock only specifi tab?
			if(message.criteria && message.criteria.tabId)	{
				this.getTabExtra(message.criteria.tabId).blocking = false;
				this.updateBrowserActionIcon(message.criteria.tabId);//just to reflect this
				logger.log('unblocking ' + message.criteria.tabId);
				
			}
		}		
	}

	/**
	 * 
	 * @param {} tabId : target tabId or an array of tabIds. If null, it won't be considered 
	 * a filter (i.e. all listeners will get the message)
	 * 
	 * @param {string} eventName : e.g. onCompleted, onSendHeaders, but also custom like 'entriesClearedNotification'
	 * 
	 * @param {object} details : event's details, e.g. if it's 'onCompleted' it's the details posted by
	 * chrome.webRequest.onCompleted.addListener. In case of custom events, this can be anything.
	 */
	notifySubscribers(tabId, eventName, details = {}){
		//isTabId an array?
		if(tabId !== null && typeof tabId === 'object' && typeof tabId.length !== 'undefined'){
			tabId.forEach((ti) => {
				this.notifySubscribers(ti,eventName, details = {});
			})
			return;
		}
		for(var n in this.subscribers){
			const subscriber = this.subscribers[n];
			const port = subscriber.port;
			let criteriaMatches = true;

			//tabId filtering?
			if(tabId !== null && subscriber.criteria &&
				typeof subscriber.criteria.tabId !== 'undefined' 
				&& subscriber.criteria.tabId != tabId
				){
				criteriaMatches = false;
			}
			
			if(criteriaMatches){
				try{
					port.postMessage({
						command: 'subscription', 
						eventName: eventName,
						details: details,
					});
				}catch(error){
					//logger.log('error with port.postMessage: ',error, ' subscribed removed');
					//most probably 'disconnected port' or similar, removing.
					delete this.subscribers[n];
				}					
			}
		}
	}

	/**
	 * Removes given whole tab from both this.tabEntries and entries in this.entries matching given tabId
	 * Used when a command is send "clearEntries" specific to given tab or when autoclearing old closed tabs.
	 * @return int  - number of entries removed.
	 */
	removeTabEntries(tabId){
		//only for informational purposes:
		let count = 0;
		//easy - removing from tabEntries
		if(this.tabEntries.has(tabId)){
			count += this.tabEntries.get(tabId).size;
			this.tabEntries.delete(tabId);
		}
		//harder - we need to find all the matching entries in this.entries.
		//previously we used to take that info (what to delete) from tabEntries but that wasn't sufficient
		//because for many reasons this information might not be up to date.
		this.entries.forEach((entry,key) => {
			if(entry.request && entry.request.tabId === tabId){
				this.entries.delete(key);
				count++;
			}
		})		
		return count;
	};

	/**
	 * Using this.tabExtras returns a matching tab info or (if missing) creates and returns it.
	 * @param {*} tabId 
	 */
	getTabExtra(tabId){
		if(tabId in this.tabsExtras){
			return this.tabsExtras[tabId];
		}
		//there can be more than this, it's dynamic
		this.tabsExtras[tabId] = {
			autoRemovedEntriesCount : 0,
		}
		return this.tabsExtras[tabId];
	}
	/**
	 * Part of auto clearing. Used in this.autoClear which is called periodically
	 * @param {int} maxEntriesPerTab - max entries per tab, if found a bigger one, then removing is done.
	 * 				We'll remove at least so many elements, that we'll reach this max
	 * @param {int} removeCountBelowMax - if any tab found higher that the max, then, since we are at this, 
	 * 				how many *more* elements should we remove, so we will not get into this process to often.
	 * 
	 * @return {array} array of tab ids which were "cleared"
	 */
	clearOversizedTabs(maxEntriesPerTab, removeCountBelowMax = 1){
		let clearedTabs = [];
		this.tabEntries.forEach((theTabEntries, tabId) => {
			if(theTabEntries.size > maxEntriesPerTab){
				const keys = theTabEntries.keys();
				let index = 0;
				//so if the max is 10 and we have 15, then well remove 5 plus removeCountBelowMax
				let removeCount = (theTabEntries.size - maxEntriesPerTab) + removeCountBelowMax;
				//logger.log('clearing ' + removeCount + ' in ' + tabId);
				for (let requestKey of keys) {
					theTabEntries.delete(requestKey);
					this.entries.delete(requestKey);
					if(++index >= removeCount){
						break;
					}
				}		
				this.addStat('Clearing oversized tabs: removed entries', removeCount);
				//we use this.statsKeys... because this particular key is used more than once:
				this.addStat(this.statsKeys.totalEntriesRemoved, removeCount);
				//some extra info per tab
				const tabExtra = this.getTabExtra(tabId);
				tabExtra.autoRemovedEntriesCount += removeCount;
				clearedTabs.push(tabId); 		
			}
		})
		return clearedTabs;
	}

	/**
	 * this one will remove those entries which a. belong to closed tabs (those which simply do not 
	 * exists any more) and b. are older than 'maxInactivityTime' minutes 
	 * 
	 * @param {int} maxInactivityTime - when (in minutes) to consider a tab for deletion 
	 * 	(how old the most recent request must be). 0 (zero) means *all* the closed tabs (and their entries)
	 * should be removed.
	 * 
	 * @return array of removed tabs id.
	 */
	clearClosedTabs(maxInactivityTime = 60){
		let clearedTabs = [];
		this.tabEntries.forEach((theTabEntries, tabId) => {
			if(!this.tabs.is (tabId)){//the tab doesn't exist anymore
				const theTabEntriesSize =  theTabEntries.size;
				if(theTabEntriesSize > 0){
					//last entry on the list (in the insertion order). The [1] here is because we want 'value' (entry)
					//[0] is the key which we're not interested in.
					const lastEntry = Array.from(theTabEntries)[theTabEntries.size - 1][1];
					const lastEntryAgeMinutes = (Date.now() -  lastEntry.request.timeStamp) / 1000 / 60;
					if(lastEntryAgeMinutes >= maxInactivityTime){
						//logger.log(`removing from tab ${tabId} aged ${lastEntryAgeMinutes} min. url: ${lastEntry.extra.tab.url}`);
						this.removeTabEntries(tabId);

						this.addStat('Clearing closed tabs: removed entries', theTabEntriesSize);
						this.addStat('Clearing closed tabs: removed tabs', 1);
						//we use this.statsKeys... because this particular key is used more than once:

						this.addStat(this.statsKeys.totalEntriesRemoved, theTabEntriesSize);
						clearedTabs.push(tabId); 	
					}
				}
			}
		});		
		return clearedTabs;
	}

	/**
	* problem: when user navigates away from a given site (but doesn't close a tab) we still keep
	* the info in this.entries - because user might want to see them in 'global scope'.
	* but by doing so, we risk that the data will become huge, in case user doesn't close the tab
	* readme: problem-old-entries
	* @param maxTime - in minutes
	* @return - count of removed entries.
	*/
	clearArchivedEntries(maxTime = 60){
		let count = 0;
		this.entries.forEach((entry,key) => {
			if(typeof entry.extra.archivedOn != 'undefined' && entry.extra.archivedOn !== null){
				const entryArchivedAgeMinutes = (Date.now() -  entry.extra.archivedOn) / 1000 / 60;
				if(entryArchivedAgeMinutes > maxTime){
					this.entries.delete(key);
				}
				count++;
			}
		})	
		this.addStat('Clearing archived tabs: removed entries', count);
		//we use this.statsKeys... because this particular key is used more than once:
		this.addStat(this.statsKeys.totalEntriesRemoved, count);	
		return count;	
	}
	/**
	 * This one should never actually be needed. And the name says its all.
	 * @return bool true if anything was removed, false if there was no need.
	 * @todo we should also remove them in tabEntries probably... 
	*/
	clearRidiculouslyHighNumberOfGlobalEntries(maxEntries = 100000){
		if(this.entries.size < maxEntries){
			return false;
		}
		const removeCount = (this.entries.size - maxEntries) + (maxEntries * 0.1);
		utils.deleteMapHead(this.entries, removeCount);

		//this.log('clearRidiculouslyHighNumberOfGlobalEntries - cleared general log (all tabs)',{removeCount: removeCount});

		this.addStat('clearRidiculouslyHighNumberOfGlobalEntries: removed entries', removeCount);
		//we use this.statsKeys... because this particular key is used more than once:
		this.addStat(this.statsKeys.totalEntriesRemoved, removeCount);
		return true;
	}

	/**
	 * In case we get too fat (too many requests recorded), here we're removing older entries.
	 * We are called here every few seconds.
	 */
	autoClear(){
		const startTime = performance.now();

		//if any tab has more than x items, remove the overhead and a few more:
		//this should be optional
		const limitPerTab = 1500;
		let clearedTabs = this.clearOversizedTabs(limitPerTab, limitPerTab * 0.1);
		if(clearedTabs.length > 0){
			//send notification to the affected tabs:
			this.notifySubscribers(clearedTabs,'entriesAutoClearedNotification');
			//this action could affect current tab stats, which we do display as browser action badge text.
			this.updateBrowserActionIcon();
			this.log(`autoClear: done clearOversizedTabs (${clearedTabs.length}) tabs cleaned`);
		}

		//when to consider a tab a candidate for a complete deletion. In in minutes since the last
		//time it was alive. 60 minutes should be fine. This is the time user still can still analyze
		//the traffic even after closing a tab.
		const maxInactivityTime = 60;
		let clearedClosedTabs = this.clearClosedTabs(maxInactivityTime);
		if(clearedClosedTabs.length > 0){
			//send notification to the affected tabs:
			this.notifySubscribers(clearedClosedTabs,'entriesAutoClearedNotification');
			this.log(`autoClear: done clearClosedTabs (${clearedClosedTabs.length}) tabs cleaned`);
		}

		//if there still is a problem, we'll remove the 'archived' entries. readme: problem-old-entries
		if(this.entries.size > 3000){
			let maxArchivedTime = 60;
			let clearedArchivedEntriesCount = this.clearArchivedEntries(maxArchivedTime);
			this.log(`autoClear: done clearArchivedEntries (${clearedArchivedEntriesCount}) entries removed`);

		}		
		//if for any reason the main tab-agnostic this.entries map also have a ridiculously large number of 
		//items. This should actually never happen.
		const limitGlobal = 100000;
		if(this.clearRidiculouslyHighNumberOfGlobalEntries(limitGlobal)){
			//send notification to all the tabs:
			this.notifySubscribers(null,'entriesAutoClearedNotification');
			this.log(`autoClear: done clearRidiculouslyHighNumberOfGlobalEntries`);
		}
		
		this.addStat('Auto clearing: total time spent (ms)', performance.now() - startTime);
	};

	/**
	 * internal log
	 * @param {string | object} message - a message or a whole object
	 * @param {object} data : optional additional data
	 */
	log(message, data = {}){
		let log = {};
		if(typeof message === 'object'){
			log = message;
		}else{
			log = {
				message: message,
				data: data,
			};
		}
		log.timeStamp = Date.now();
		//logger.log('internal log:', log);
		this.logs.push(log);
	}

	/**
	 * Adding a value (number) to internal stats
	 * @param {string} key 
	 * @param {number} numberToAdd 
	 */
	addStat(key, numberToAdd){
		if(key in this.stats === false){
			this.stats[key] = 0;
		}
		this.stats[key] += numberToAdd;
	}
	/**
	 * Internal stats - i.e. this.stats plus some other things
	 */
	getStats(){
		const result = this.stats;
		result['Currently: entries'] = this.entries.size;
		result['Currently: tabs in tab entries'] = this.tabEntries.size;
		result['Currently: entries in tab entries'] = Array.from(this.tabEntries).
			reduce((acc,item) => {
				return acc + item[1].size;
			},0);
		result['Currently: active tabs'] = Object.keys(this.tabs.tabs).length;
		result['Currently: subscribers'] = this.subscribers.length;
		//total time since we started, *not* "time spent on processing"
		result['Currently: minutes since start'] = (Date.now() - this.startTime) / 1000 / 60;
		return result;
	}
}

const monitor = new Monitor();
monitor.run();

/**
 * this one is different than the Monitor::onMessage. The latter is based on a port and this one here
 * is just a one time message, mostly for debug purposes.
 */
chrome.runtime.onMessage.addListener(
  function(message, sender, sendResponse) {
	//console.log on behalf of something else, like browser action popup (to make it easier on chrome)
	if(message.command == "logPlease"){
		logger.log('from ' + message.from + ':');
		logger.log(message.param);
	}

});



<|start_filename|>src/inject/inject.js<|end_filename|>
/*
function init(){
}

if (document.readyState === "loading") {//might be 'complete' or 'loading'
    document.addEventListener("DOMContentLoaded", init);
} else {//dom already loaded, so the above event will never fire, so:  
    init();
}
*/

<|start_filename|>src/common/app.js<|end_filename|>

/**
 * some common stuff shared among application elelements like background, content, action scripts.
 * and the inzoom itself (indirectly) 
 * 
 * including 'defaultConfig' which actually might go somewhere else.
 */
const app =  {
    /*
    default configuration, this will be saved to storage.local and from now on will be used from there.
    except for testing the files outside an extension - this is the only source of config values then.
    This key will be saved to the storage.local (or similar)
    */     
    defaultConfig: {

    },

    evnironment : 'dev',//'prod' or 'dev'.
    //evnironment : 'prod',//'prod' or 'dev'.

    isDev : function(){
        return (this.evnironment && this.evnironment.toUpperCase()  == 'DEV');
    },
};

/**
 * Logger: a console.log which can be globally enabled/disabled, with a prefix
 * **and** showing the right file and line number in console (i.e. wherever the function was called)
 * 
 * Usage example: 
 * 
 * var logger = new Logger('module xyz:');
 * logger.log('blah');
 * 
 * or enabled/disabled depending on environment (dev/prod):
 * var logger = new Logger('module xyz:',app.isDev());
 * 
 * the above will output module xyz: blah and report the right file and line number 
 */
class Logger{
	constructor(prefix = '', enabled = true){
        if(!enabled){
            this.log = function(){};
            return;
        }        
        this.log = function() {
            return Function.prototype.bind.call(console.log, console, prefix);
        }();
	}
}

/**
 * just a request and a matching response
 */
class Entry{

	constructor(){
        //request and reponse come directly from browser
        this.request = this.response = {};
        //'extra' is something this app fills.
        this.extra = {

            //*some* information from the tab making the request, at the time it is making it.
            //mainly we have the .url here. Or at least should have, this isn't critically important
            //for background.js to grab it.
            tab: {},

            //time in ms. 
            //here we're just using request timestamp and response timestamp to calculate the difference.
            //which basically should be a 'server reponse time' or rather 'download time'. 
            //For some reason it is a bit different than what we see in dev tools -> network, 
            //but this measurement can still be good when comparing to other requests' times reported 
            //by this extension. It's just comparing it to other tools and methods might bring different 
            //results.
            time: null,

            //did *we* (the extension) block this request. 
            blocked: false,

            //for autocleaning purposes. This is a timestamp, when user navigated to another site in a tab
            //so we know that sooner or later this entry should be removed (not immedietally, because for
            //some time we want to keep it, so user can see it in 'global view'. Even if this entry isn't 
            //visible anymore in 'tab scope') readme: problem-old-entries
            archivedOn: null,
        };
	}	
}

/**
 * Get unique key for a request
 */
function getKey(details) {
  return details.requestId + '_-_' + details.url;
}


<|start_filename|>src/options/script.js<|end_filename|>

function init(){
    document.querySelector('h1').style.color = '#ada';
}

if (document.readyState === "loading") {//might be 'loading' or 'complete'
    document.addEventListener("DOMContentLoaded", init);
} else {//dom already loaded, so the above event will never fire, so:  
    init();
}



<|start_filename|>src/common/utils.js<|end_filename|>
/**
 * Just some utililities not really very much specific for this particular app.
 */
utils = {

    /**
     * this one says if a given string looks like it was a regex i.e. it is wrapper in // 
     * '/blah/' true
     * 'blah' false
     * '/blah' false. Same for '//' or just '/'
     * @param {string} s 
     */
    hasRegexDelimeters(s){
        return /^\/.*\/$/.test(s);
    },
    /**
     * escapes any special regex chars, like ,*? etc, so they'll be used very literally. 
     * https://stackoverflow.com/a/9310752/4568686
     * 
     */ 
    escapeRegExp(text) {
        return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
    },

    /**
     * DOM: removes all the child nodes of a given node. In some cases better (and surprisingly faster)
     * than using .innerHTML = '';
     */
    removeChildren (parentNode) {
        let last;
        while (last = parentNode.lastChild) {
            parentNode.removeChild(last);
        }
    },
    
    /**
     * Deletes the very first N elements of a Map. In the insertion order.
     * @param {Map} map 
     * @param {int} count 
     */
    deleteMapHead(map, count = 1){
        const keys = map.keys();
        let index = 0;
        for (var key of keys) {
    	    map.delete(key);
    	    if(++index >= count){
    	        break;
    	    }
        }
    },

    /**
     * 
     * @param {number} timeSpan miliseconds
     */
    formatTimeSpan(timeSpan, digits = 2){
        if(timeSpan < 1000){
            return timeSpan.toFixed(digits) + ' ms';
        }else if(timeSpan < (1000 * 60)){
            return (timeSpan / 1000).toFixed(digits) + ' s';
        }else {
            return (timeSpan / (1000 * 60)).toFixed(digits) + ' min';
        };
    },

    /**
     * adds ... to a string if longe than 'maxLen'
     */
    ellipsis (string, maxLen){
        if (string.length >= maxLen)
           return string.substring(0,maxLen-1)+'...';
        else
           return string;
     },    

    //see  colorFromNumber
    rgbFromNumber(num){
        return {
                'r' : Math.abs(num % 255),
                'g' : Math.abs((num % 255) * 40) % 255,
                'b' : Math.abs((num % 255) * 80) % 255,
            };
    },

    /**
     * will try to 'make' a color based on arbitrary number (from 0 to ... whatever).
     * used e.g. to colorize tab IDs in such a way, that tab 1001 will differt greatly (in color) from 10002;
     * @return {string} e.g. rgb(10,20,300);
     */
    colorFromNumber(num){
        const c = this.rgbFromNumber(num);
        return `rgb(${c.r},${c.g},${c.b})`;
    },  

    //used by distinctColorFromNumber, taken from: https://sashat.me/2017/01/11/list-of-20-simple-distinct-colors/
    distinctColors : [
        '#e6194B', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', 
        '#bfef45', '#fabebe', '#469990', '#e6beff', '#9A6324', '#fffac8', '#800000', '#aaffc3', 
        '#808000', '#ffd8b1', '#000075', '#a9a9a9', '#ffffff', '#000000'],
    /**
     * returns a 'distinct' color from a number, used e.g. to colorize tabId's which can be any number
     * from 0 to ... a lot of. 
     * */    
    distinctColorFromNumber(num){
        return this.distinctColors[num % this.distinctColors.length];
    }
};

<|start_filename|>src/modules/list/style.css<|end_filename|>

*, *::before, *::after { box-sizing: border-box; }

:root{
    /* modal dialog */
    --dlg-width:500px; 

    --toolbar-height:36px;
    --statusbar-height:26px;
}

html{
    height:100%;
}

body{
    font-family: sans-serif; 
    font-size: 13px;/* default in chrome extensions and also dev tools , so we're normalizing here */
    margin:0px;
    color: #333;
    background-color: #eee;
    height:100%;
}

main {
    position:relative;
    /* height of header element */
    top: calc(1em + 2px);
    height:calc(100% - 1em - 2px);
}


/* this means we are in a popup */
@media (max-width: 800px) {
    html{
        /* max for both fx and chrome */
        width:800px;
        height:300px;
    }
}

/* this means we are probably in full mode */
@media (min-width: 801px) {
    html{
        width:100%;
    }
    :root{
        --dlg-width:600px
    }
}

header{
    display: flex;
    position:fixed;
    justify-content: center;

    margin: 0;
    padding: 1px 0;
    width: 100%;

    background-color: #444;
    color:#ccc;
    font-size: smaller;
    
}
/* most if these things exist to have fixed header position */

#entries-table{
    width: 100%;
    background-color: #fff;
    cursor: default ; 

    height:calc(100% - var(--toolbar-height) - var(--statusbar-height) - 1px);
    display:block;
    overflow:hidden;
}


#entries-table thead{
    overflow-y: scroll;
    display: table;
    table-layout: fixed;
    
    width: calc(100% - 16px); /* assuming scrollbar width as 16px */
    background-color: #eaeaea;
}      
    
#entries-table tbody{
    
    table-layout: fixed;
    display: block;
    overflow: auto;
    overflow-y: scroll;

    /* height: 200px;  */
    height:calc(100% - 21px);/*assuming thead has this number of pixels in height */
}

#entries-table tr{
    width: 100%;
    height:2em;
    display: table;
    table-layout: fixed;

}

#entries-table tr td, 
#entries-table tr th{
    /* border:1px solid #eaeaea; */
    border-bottom:1px solid #eaeaea;
    height:1.5em;
    padding-left:5px;
    padding-right:5px;
    text-align: left;
}
#entries-table tr th{
    border:1px solid #dadada;
}
#entries-table td span{
    display: block;
    white-space: nowrap;
    overflow:hidden;
    text-overflow: ellipsis;
}

#entries-table tr:nth-child(even) {
    background-color:#f4f4f4;
}

#entries-table tbody tr:hover {
    background-color:#f1f6fd;
}

#entries-table tbody tr:nth-child(even):hover {
    background-color:#f1f6fd;
    /* background-image: linear-gradient(0deg, rgba(0,0,255,0.1) 0%, rgba(0,0,255,0.1) 100%); */
}

/*
the row represents the key (main) request, which ususally is a *page* opened just after navigating
to some url.
*/
#entries-table  tr.item-main-frame{
    /* font-weight: bold; */
    background-color:#d1d6e5;
}

/*
Server error item, like 404. Applies to the whole tr
*/
#entries-table  tr.item-server-error{
    color: #E71010;
}

/*
Client error item, like 'blocked by client', applies to the whole tr element.
*/
#entries-table  tr.item-client-error{
    color: #E74010;
}

/*
Redirect, like 307. Applies to the whole tr
*/
#entries-table tr.item-http-redirect {
    color: #1010E7;
}


/** specific columns **/

#entries-table .col-slim{
    /* width:55px; */
    width:55px;
}

#entries-table .col-tab-title{
    /* width:55px; */
    width:120px;
}

#entries-table .col-type{
    width:80px;
}

#entries-table  .col-url-domain{
    width:20%;
}


#entries-table  .col-url-name{
    width:40%;
}

/*
-------------------------------------------------------------------------------
bars, toolbar, status bar,
-------------------------------------------------------------------------------
*/

.bar{
    overflow:hidden;
    display: flex;
    align-items: center;    
}
.bar .bar-left{
    flex:auto;
}
.bar .bar-right{
    padding-right:10px;
}

/* label and checkboxes - without it they seem misaligned  */
.bar label input{
    position:relative;
    top:3px;
}

/*
-------------------------------------------------------------------------------
toolbar
-------------------------------------------------------------------------------
*/

#toolbar {
    padding:5px 3px 5px 3px;
    height: var(--toolbar-height);
}

#toolbar #toolbar-right{
    /* float:right;
    margin-right:20px; */
}

/*eg. text filter*/
#toolbar input[type='text']{
    height:26px;
    padding-left:3px;
}

#toolbar .button{
    border:none;
    background-color:inherit; 
    opacity:0.7;
    /* min-width:26px; */
    padding-left:3px;
    padding-right:3px;
    font-size:0.9em;
}

#toolbar .button:hover{
    /* background-image: linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1)); */
    opacity: 1;
}

#toolbar .button:focus{
    border:none;
    outline:none;
}

/* 'pressed' is only useful for js, not for styling, for styling there is .pressed-highlight */
#toolbar .button.pressed{

}

#toolbar .button.pressed-highlight{
    color:#0cf;
}

#toolbar .button#blocking.pressed-highlight{
    color:#902C20;
}


#toolbar .button a .fa-icon{
    color:#333;
}


/*
-------------------------------------------------------------------------------
status bar
-------------------------------------------------------------------------------
*/

#statusbar{
    padding:3px;
    border:1px solid #ccc;
    border-right:none;
    border-left:none;
}

#statusbar #entries-removed-warning{
    color:#902C20;
}
/*
-------------------------------------------------------------------------------
modal dialog
-------------------------------------------------------------------------------
*/
.modal{
    position:absolute;
    top: 10px;
    width: var(--dlg-width);
    /* height: 200px; */
    left: calc(50% - var(--dlg-width)/2);
    padding: 5px;
    /* font-size:1.1em; */
    border: 1px solid #555;
    border-radius: 5px; 
    background-color:#fafafa; 
    
    color:#444;
    display:none;
}
  
.modal.modal-visible{
    display:block;
}

.modal .content{
    margin-bottom:5px;
}

/*the 'x' on the top right */
.modal .modal-btn-close{
    position: absolute;
    right: 7px; 
    top: 1px;
    padding-top: 3px;
    /* background: #fff; */
    /* width: 25px; */
    height: 25px;
    font-weight: bold;
    text-align: center;
    cursor: pointer;
    /* color: #aaa; */
    opacity:0.7;
    
}

.modal .modal-btn-close:hover { 
    opacity:1; 
}  

.modal .modal-btn-ok {
    /* position: absolute; */
    bottom: 20px;
    right: 30px;
    min-width: 50px;
    height: 32px;
    padding:9px;

    text-align: center;
    cursor: pointer;
    background: #456CB3;
    color: #fff;
    border-radius: 2px;
    border: none;
    font-size: 14px;
    font-weight: normal;
    box-sizing: border-box;
}

.modal h1{
    font-size:1.2em;
    color :#111;
}

.modal h2{
    font-size:1.1em;
    font-weight:bold;
    margin:12px 0px;
    border-bottom:1px solid #eee;
    max-width:97%;
    color :#111;
}

/* the entire detail's container (which might be then a child of a dialog */
.entry-details{
    margin-top:1.5em;
    /* max-height:200px; */
    max-height:70vh;
    overflow:auto;
    word-wrap:break-word;
    font-size:0.95em;
    
}
.entry-details .row {
    margin-bottom:0.3em;
}
.entry-details .row .name{
    font-weight: bold;
    display:inline-block;
    margin-right:0.5em;
}

.entry-details .row .value {
    
}

/*
-------------------------------------------------------------------------------
other elements
-------------------------------------------------------------------------------
*/

#loader{
    display: none;
    position:absolute;
    top:45%; 
    left:45%;
    font-size:80%;
    color:#555;
    background-color:white;
    /* border:1px solid #bbb; */
    padding:5px;
    opacity:0.8;

}

/*
-------------------------------------------------------------------------------
just some common stuff
-------------------------------------------------------------------------------
*/


<|start_filename|>src/modules/list/table.js<|end_filename|>

/**
 * html <table> service. We're not using any wrapper for dom modification to make everything as fast
 * as possible.
 */
class Table{
  
    constructor(tableElement, columns){
        this.tableEl = tableElement;
        this.tableHeadEl = document.querySelector('thead',tableElement);
        this.tableBodyEl = document.querySelector('tbody',tableElement);
        this.setColumns(columns);
        
        
        //autoscrolling 
        this.autoScrollEnabled = true;
        this.autoScrollJustDone = false;
        this.tableBodyEl.addEventListener('scroll',(ev)=>{
        //did *user* do the scroll? If so, then maybe we will enable / disable autoscroll.
        if(!this.autoScrollJustDone){
            //did he/she scroll to the bottom? If not, then we'll disable autoscroll
            this.autoScrollEnabled = (this.tableBodyEl.scrollTop === this.tableBodyEl.scrollHeight - this.tableBodyEl.clientHeight);
        }
        this.autoScrollJustDone = false;
        })
        
        //adjusting styles (widths mainly)
        setInterval(()=>{
            this.adjustStyles();
        },100)
    }

    /*
    @param columns: array of {object,val,display} where object is request or response.
    */
    setColumns(columns){
        this.columns = columns;
    }
    
    /**
     * 
     * @param {Map} entries 
     */
    /*
    setEntries(entries){
      this.entries = entries;
    }
    */

    //remove head and or body
    remove(removeHead = true, removeBody = true){
        if(removeHead){
            utils.removeChildren(this.tableHeadEl);
        }
        if(removeBody){
            utils.removeChildren(this.tableBodyEl);
        }        
    }

    make (entries = null){
        if(!this.tableEl){
            console.error('tableEl is empty');return;
        }
        const head = this.makeHead();
        this.tableHeadEl.appendChild(head);
        if(entries){
            this.addRows(entries);
        }
    }
    
    makeHead(){
      var fragment = document.createDocumentFragment();
      //var table = document.createElement('table');
      const tr = document.createElement('tr');
      const _th = document.createElement('th');

      //accepting both arrays and objects
      for (var colIndex in this.columns) {
        const column = this.columns[colIndex];
        const th = _th.cloneNode(false);
        

        th.textContent = column[0];//0: display
        if(column[3]){//css class
            th.classList.add('col-' + column[3]);
        }
        tr.appendChild(th);  
      }
      fragment.appendChild(tr);
      return fragment;
    }

    makeRow(entry){
        const tr = document.createElement('tr');
        tr.setAttribute('data-requestKey',getKey(entry.request));
        const _td = document.createElement('td');   
        const _span = document.createElement('span');
        const url = new URL(entry.request.url);
        let trClass = [];//might be something in case of statusCode == 404 etc.
        //console.log(entry,JSON.stringify(entry,null,' '));
        for (var colIndex in this.columns){ 
            //whole line like ['Type','request','type','type']
            //or ['Domain','special','host','url-domain']:
            const column = this.columns[colIndex];
            const td = _td.cloneNode(false);
            const span = _span.cloneNode(false);

            let tdText = '';
            let tdClass = [];
            let tdStyle = '';//this one is used only when coloring 'tab info' cell
            let tdTitle = '';
            if(column[3]){//css class
                tdClass.push('col-' + column[3]);
            }              
            if(column[1] === 'special'){//we must handle this manually
                if(column[2] === 'showDetails'){
                    tdText = '𝄙';
                    tdClass.push ('showDetails');
                }
                if(column[2] === 'host'){
                    tdText = url.host;
                    tdTitle = entry.request.url;
                }
                if(column[2] === 'pathNameSmart'){
                    tdTitle = entry.request.url;
                    const path = url.pathname;              
                    let name = path.substring(path.lastIndexOf("/") + 1, path.length);
                    if(url.search){
                        name += url.search;
                    }
                    tdText = name.length > 0?name : url.host;
                }

                if(column[2] === 'tabTitle'){
                    //title isn't guaranteed to contain anything. It's only there when domdocument is fully
                    //loaded.
                    tdText = entry.extra.tab.title ? entry.extra.tab.title : entry.extra.tab.url;
                    tdTitle = entry.extra.tab.title + ' (' + entry.extra.tab.url + ')';
                    //border color:
                    const color = utils.distinctColorFromNumber(entry.request.tabId);
                    tdStyle = `border-left:3px solid ${color};`;
                    if(app.isDev()){
                        tdTitle += ` [tabId: ${entry.request.tabId}]`;
                    }
                }
                if(column[2] === 'time'){
                    let totalTime = '';
                    if(entry.extra.time){
                        tdText  = utils.formatTimeSpan(entry.extra.time,0);
                        tdTitle = utils.formatTimeSpan(entry.extra.time,2);     
                    }                    
                }                
                //['Tab','special','tabTitle','slim'],
            }else {//not a 'special' column
                //for starters:
                tdText = entry[column[1]][column[2]];
                if (column[2] === 'type'){
                    if(tdText === 'xmlhttprequest'){
                        tdText = 'xhr';
                    }
                } else if (column[2] === 'statusCode'){
                    if(typeof tdText == 'undefined' || tdText == ''){
                        tdText = 'waiting...';
                        if(entry.response && entry.response.error){
                            tdText = 'error';//initially
                            /*
                            //@todo: just maybe : this one appears when ublock blocks, but only then? 
                            //Besides in FX it's a different message. 
                            if(entry.response.error === 'net::ERR_BLOCKED_BY_CLIENT'){
                                tdText = 'blocked';
                            }*/
                            if(entry.extra.blocked){//this means *we* (the extension) blocked it.
                                tdText = 'blocked';
                            }
                            tdTitle = entry.response.error;//e.g. net::ERR_BLOCKED_BY_CLIENT when blocked by ublock or adblock
                            
                        }
                    }
                }
            }
            if(entry.request && entry.request.type === 'main_frame' && entry.request.frameId == 0){
                trClass.push('item-main-frame');
            }
            //styling tr (e.g. based on statusCode, this doesn't depend on columns being displayed) 
            if(entry.response){
                //it is possible that even when 'response' is there, the statusCode is not. This happens
                //when there is response.error (handled further below)
                if(entry.response.statusCode){
                    const statusCodeInt = parseInt(entry.response.statusCode);  
                    if(entry.response.isRedirect){
                        //Redirection
                        //Check this condition first because redirect with response status 200 can happen if
                        //another extension redirect the request.
                        trClass.push('item-http-redirect');
                    }else if(statusCodeInt >= 200 && statusCodeInt <= 299){
                        //OK
                    }else if (statusCodeInt >= 400){
                        //Error
                        trClass.push('item-server-error');
                    }   
                }
                if(entry.response.error){
                    //client (e.g. via some ad blocker) blocked or something else
                    trClass.push('item-client-error');
                }
            }

            //ok, lets build it.
            span.textContent = tdText;
            if(tdClass.length > 0){
                td.classList.add(...tdClass);
            }
            if(tdStyle != ''){
                td.style.cssText = tdStyle;
            }
            if(tdTitle){
                td.setAttribute('title',tdTitle);
            }
            
            td.appendChild(span);
            tr.appendChild(td);  
          }   

        if(trClass.length > 0){
            tr.classList.add(...trClass);  
        }
        return tr;
    }
    
    makeRows(entries){
        var fragment = document.createDocumentFragment();
        entries.forEach(entry => {
            const tr = this.makeRow(entry);
            fragment.appendChild(tr);
        });
        return fragment;
    }
    
    /*
    use when the table is already built - this one will add a row to existing table.
    */
    addRow(entry){
        const tr = this.makeRow(entry);
        this.tableBodyEl.appendChild(tr);
        if(this.autoScrollEnabled){
            this.scrollToBottom();
        }
    }

    addRows(entries){
        const rows = this.makeRows(entries);
        this.tableBodyEl.appendChild(rows);
        if(this.autoScrollEnabled){
            this.scrollToBottom();
        }        
    }

    findRow (entry){
        return document.querySelector('tr[data-requestkey="'+getKey(entry.request)+'"]',this.tableEl);
    }

    /**
     * updates info in a row, by entry
     */ 
    updateRow(entry){
        const trEl = this.findRow(entry);
        if(!trEl){
            return null;
        }
        trEl.replaceWith(this.makeRow(entry));
        return trEl;
    }
    scrollToBottom(){
        this.autoScrollJustDone = true;
        this.tableBodyEl.scrollTop = this.tableBodyEl.scrollHeight - this.tableBodyEl.clientHeight;
        
    }
    adjustStyles(){
        const headWidth = this.tableHeadEl.clientWidth;
        const bodyWidth = this.tableBodyEl.clientWidth;
        if(headWidth !== bodyWidth){
            this.tableHeadEl.style.width = bodyWidth + 'px';  
            //console.log('changed to ' + bodyWidth + 'px')
        }
        //head.style.width = body.clientWidth + 'px';
    }
  }
  

<|start_filename|>src/modules/list/details.js<|end_filename|>
/**
 * dialog window (or just a div) showing the entry details
 * We avoid using .innerHTML here, both mozilla and google don't like it.
 * It's is *not* modal dialog, hence no 'close' method etc. Here we're only responsible for the
 * details view and it can be used in other places as well.
 */
class EntryDetails{
    constructor(container){
        this.container = container;
        this.template = `
        `; 
    }

    /**
     * creates a new <a> element, doesn't add it anywhere
     * params: <a href = <href> title = <title> target = <target>>text</a>
     * target can be e.g. '_blank'
     */
    createLink (href, text = null, title = null, target = null){
        if(text === null){
            text = href;
        }
        let linkEl = document.createElement('a');
        linkEl.setAttribute('href',href);
        if(target){
            linkEl.setAttribute('target',target);
        }
        if(title){
            linkEl.setAttribute('title',title);
        }
        linkEl.textContent = text;
        return linkEl;
    }

    makeTitle(parent, title){
        let titleEl = document.createElement('h2');
        titleEl.textContent = title;
        parent.appendChild(titleEl);
    }

    /**
     * a new row like: <div><span>name</span><span>value</span></div>
     * @param {string | Node}  value - can be either a simple string or an htmlelement
     */
    makeRow(parent, name, val){
        const rowEl = document.createElement('div')
        rowEl.classList.add('row');

        const nameEl = document.createElement('span');
        nameEl.classList.add('name');
        nameEl.textContent = name + ':';

        const valEl = document.createElement('span');
        valEl.classList.add('val');
        if(val instanceof Node){
            valEl.appendChild(val);
        }else{
            valEl.textContent = val;
        }

        rowEl.appendChild(nameEl);
        rowEl.appendChild(valEl);

        parent.appendChild(rowEl);
    }

    //in place header array sort. Changes passed array
    headersSort(array){
        array.sort((a,b) => {
            var nameA = a.name.toUpperCase(); 
            var nameB = b.name.toUpperCase();
            if (nameA < nameB) {
              return -1;
            }
            if (nameA > nameB) {
              return 1;
            }
          
            // names must be equal
            return 0;        
        })
    }

    /**
     * Converts timestamp to hh:mm:ss
     * @param timestamp in miliseconds (not unix' seconds!)
     */
    formatTime(timestamp){
        var date = new Date(timestamp);
        var hours = "0" + date.getHours();
        // Minutes part from the timestamp
        var minutes = "0" + date.getMinutes();
        // Seconds part from the timestamp
        var seconds = "0" + date.getSeconds();
        
        // Will display time in 10:30:23 format
        return hours.substr(-2) + ':' + minutes.substr(-2) + ':' + seconds.substr(-2);        
    }

    setEntry(entry){
        //console.log(entry);

        utils.removeChildren(this.container);

        entry = JSON.parse(JSON.stringify(entry));
        

        this.makeTitle(this.container, 'General');
        const urlLinkEl = this.createLink(entry.request.url,entry.request.url,null,'_blank');
        this.makeRow(this.container,'Request URL', urlLinkEl);
        this.makeRow(this.container,'Request Method', entry.request.method);
        this.makeRow(this.container,'Status Code', entry.response.statusCode);
        if(entry.response.error){
            this.makeRow(this.container,'Error', entry.response.error);
        }
        this.makeRow(this.container,'Started', this.formatTime(entry.request.timeStamp));
        this.makeRow(this.container,'Total time', 
            entry.extra.time ? utils.formatTimeSpan(entry.extra.time) : '?');


        this.makeTitle(this.container, 'Request headers');
        if(entry.request.headers){
            this.headersSort(entry.request.headers);
            entry.request.headers.forEach(element => {
                this.makeRow(this.container, element.name, element.value);
            });
        }                        
                

        this.makeTitle(this.container, 'Response headers');
        if(entry.response.headers){
            this.headersSort(entry.response.headers);
            entry.response.headers.forEach(element => {
                this.makeRow(this.container, element.name, element.value);
            });
        }

        this.makeTitle(this.container, 'Other');
        this.makeRow(this.container,'Initiator', entry.request.initiator);
        this.makeRow(this.container,'From cache', entry.response.fromCache);
        
        if(typeof app !== 'undefined' && app.isDev()){
            this.makeTitle(this.container, 'Debugging info (dev mode only)');
            const preEl = document.createElement('pre');
            this.makeRow(preEl,'Whole entry jsoned', JSON.stringify(entry, null, 4));
            this.container.appendChild(preEl);
        }
    }
}